1、调整凭证
2、调整运单
This commit is contained in:
@@ -5,6 +5,19 @@ const baseUrl = '/blade-transport/voucher-manage';
|
||||
export const getList = (current, size, params) =>
|
||||
request({ url: `${baseUrl}/list`, method: 'get', params: { current, size, ...params } });
|
||||
export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } });
|
||||
export const getFolderPage = (voucherId, current, size, params) =>
|
||||
request({ url: `${baseUrl}/folder-page`, method: 'get', params: { voucherId, current, size, ...params } });
|
||||
export const getFolderDetail = (voucherId, plateNo) =>
|
||||
request({ url: `${baseUrl}/folder-detail`, method: 'get', params: { voucherId, plateNo } });
|
||||
export const replaceFolder = (voucherId, plateNo, file) => {
|
||||
const data = new FormData();
|
||||
data.append('voucherId', voucherId);
|
||||
data.append('plateNo', plateNo);
|
||||
data.append('file', file);
|
||||
return request({ url: `${baseUrl}/folder-replace`, method: 'post', data });
|
||||
};
|
||||
export const removeFolder = (voucherId, plateNo) =>
|
||||
request({ url: `${baseUrl}/folder-remove`, method: 'post', params: { voucherId, plateNo } });
|
||||
export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post', data });
|
||||
export const createUploadDraft = data =>
|
||||
request({ url: `${baseUrl}/upload-draft`, method: 'post', data });
|
||||
@@ -17,6 +30,9 @@ export const getWaybillBatches = (current, size, params) =>
|
||||
method: 'get',
|
||||
params: { current, size, ...params },
|
||||
});
|
||||
export const auditPass = id => request({ url: `${baseUrl}/audit-pass`, method: 'post', params: { id } });
|
||||
export const auditReject = (id, rejectReason) =>
|
||||
request({ url: `${baseUrl}/audit-reject`, method: 'post', params: { id, rejectReason } });
|
||||
|
||||
const fileTaskBaseUrl = '/blade-file/fileTask';
|
||||
export const queryFileTask = md5 =>
|
||||
|
||||
@@ -16,6 +16,10 @@ export const getFormalOptions = (current, size, params) =>
|
||||
export const save = data => request({ url: `${baseUrl}/save`, method: 'post', data });
|
||||
export const remove = id => request({ url: `${baseUrl}/remove`, method: 'post', params: { id } });
|
||||
export const match = id => request({ url: `${baseUrl}/match`, method: 'post', params: { id } });
|
||||
export const matchPreview = data =>
|
||||
request({ url: `${baseUrl}/match-preview`, method: 'post', data });
|
||||
export const manualMatch = data =>
|
||||
request({ url: `${baseUrl}/manual-match`, method: 'post', data });
|
||||
export const unmatch = internalId =>
|
||||
request({ url: `${baseUrl}/unmatch`, method: 'post', params: { internalId } });
|
||||
export const adjust = data => request({ url: `${baseUrl}/adjust`, method: 'post', data });
|
||||
@@ -23,6 +27,8 @@ export const updateByMatch = id =>
|
||||
request({ url: `${baseUrl}/update-by-match`, method: 'post', params: { id } });
|
||||
export const complete = id =>
|
||||
request({ url: `${baseUrl}/complete`, method: 'post', params: { id } });
|
||||
export const completeWithData = data =>
|
||||
request({ url: `${baseUrl}/complete-with-data`, method: 'post', data });
|
||||
export const template = mode =>
|
||||
request({ url: `${baseUrl}/template`, method: 'get', params: { mode }, responseType: 'blob' });
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="地图选择地址"
|
||||
width="920px"
|
||||
append-to-body
|
||||
@opened="initMap"
|
||||
>
|
||||
<el-dialog v-model="visible" title="地图选择地址" width="920px" append-to-body @opened="initMap">
|
||||
<div class="address-map-picker__toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
@@ -15,7 +9,14 @@
|
||||
/>
|
||||
<el-button type="primary" :loading="searching" @click="searchKeyword">搜索</el-button>
|
||||
</div>
|
||||
<div ref="map" class="address-map-picker__map"></div>
|
||||
<div class="address-map-picker__content">
|
||||
<map-search-results
|
||||
v-if="searchResults.length"
|
||||
:results="searchResults"
|
||||
@select="selectSearchResult"
|
||||
/>
|
||||
<div ref="map" class="address-map-picker__map"></div>
|
||||
</div>
|
||||
<div class="address-map-picker__info">{{ status }}</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
@@ -54,6 +55,8 @@ export default {
|
||||
amap: null,
|
||||
marker: null,
|
||||
geocoder: null,
|
||||
searchResults: [],
|
||||
mapInitializing: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -65,6 +68,7 @@ export default {
|
||||
this.keyword = this.address || '';
|
||||
this.selected = {};
|
||||
this.status = '可搜索地址或点击地图选点';
|
||||
this.searchResults = [];
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -96,25 +100,39 @@ export default {
|
||||
return amapLoader;
|
||||
},
|
||||
initMap() {
|
||||
this.loadAmap()
|
||||
if (this.mapInitializing) return this.mapInitializing;
|
||||
|
||||
this.mapInitializing = this.loadAmap()
|
||||
.then(() => {
|
||||
this.$nextTick(() => {
|
||||
if (!this.amap) {
|
||||
this.amap = new window.AMap.Map(this.$refs.map, {
|
||||
center: [116.40769, 39.89945],
|
||||
zoom: 11,
|
||||
});
|
||||
this.amap.on('click', event => this.pickPoint(event.lnglat));
|
||||
} else {
|
||||
this.amap.resize();
|
||||
this.clearMarker();
|
||||
}
|
||||
if (!window.AMap?.Map) {
|
||||
throw new Error('高德地图组件未就绪');
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
this.$nextTick(() => {
|
||||
if (!this.$refs.map) return resolve(null);
|
||||
if (!this.amap) {
|
||||
this.amap = new window.AMap.Map(this.$refs.map, {
|
||||
center: [116.40769, 39.89945],
|
||||
zoom: 11,
|
||||
});
|
||||
this.amap.on('click', event => this.pickPoint(event.lnglat));
|
||||
} else {
|
||||
this.amap.resize();
|
||||
this.clearMarker();
|
||||
}
|
||||
resolve(this.amap);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error('高德地图组件加载失败,请稍后重试');
|
||||
this.visible = false;
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.mapInitializing = null;
|
||||
});
|
||||
return this.mapInitializing;
|
||||
},
|
||||
ensureGeocoder() {
|
||||
if (this.geocoder) return Promise.resolve(this.geocoder);
|
||||
@@ -154,15 +172,28 @@ export default {
|
||||
this.$message.warning('请输入地址关键词');
|
||||
return;
|
||||
}
|
||||
if (this.searching) return;
|
||||
this.searchResults = [];
|
||||
this.searching = true;
|
||||
this.loadAmap()
|
||||
.then(() => this.runGeocode('location', keyword))
|
||||
this.initMap()
|
||||
.then(map => (map ? this.runGeocode('location', keyword) : null))
|
||||
.then(result => {
|
||||
const point = result.geocodes?.[0]?.location || result.location;
|
||||
if (!result) return;
|
||||
const geocodes = result.geocodes || (result.location ? [result] : []);
|
||||
this.searchResults = geocodes.map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || item.address || keyword,
|
||||
address: item.formattedAddress || item.address || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = geocodes[0]?.location || result.location;
|
||||
if (!point) {
|
||||
throw new Error('未找到匹配地址');
|
||||
}
|
||||
this.pickPoint(point, keyword);
|
||||
return this.$nextTick().then(() => {
|
||||
this.amap?.resize();
|
||||
this.pickPoint(point, geocodes[0]?.formattedAddress || keyword);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.status = '未找到匹配地址';
|
||||
@@ -172,6 +203,11 @@ export default {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
selectSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickPoint(point, fallbackAddress = '') {
|
||||
const longitude = typeof point.getLng === 'function' ? point.getLng() : point.lng;
|
||||
const latitude = typeof point.getLat === 'function' ? point.getLat() : point.lat;
|
||||
@@ -181,7 +217,11 @@ export default {
|
||||
}
|
||||
const lnglat = new window.AMap.LngLat(Number(longitude), Number(latitude));
|
||||
this.renderMarker(lnglat);
|
||||
this.selected = { longitude: Number(longitude), latitude: Number(latitude), address: fallbackAddress };
|
||||
this.selected = {
|
||||
longitude: Number(longitude),
|
||||
latitude: Number(latitude),
|
||||
address: fallbackAddress,
|
||||
};
|
||||
this.status = '正在解析地址...';
|
||||
this.resolving = true;
|
||||
this.runGeocode('address', lnglat)
|
||||
@@ -238,11 +278,19 @@ export default {
|
||||
}
|
||||
|
||||
&__map {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 420px;
|
||||
border: 1px solid #eff1f7;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
margin-top: 10px;
|
||||
color: #606266;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div v-if="results.length" class="map-search-results">
|
||||
<div class="map-search-results__title">搜索结果</div>
|
||||
<el-empty v-if="!results.length" description="暂无搜索结果" :image-size="60" />
|
||||
<div
|
||||
v-for="(item, index) in results"
|
||||
:key="item.id || index"
|
||||
class="map-search-results__item"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
<div class="map-search-results__name">{{ item.name || item.address || '未命名地址' }}</div>
|
||||
<div class="map-search-results__address">{{ item.address || item.name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
results: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['select'],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.map-search-results {
|
||||
width: 260px;
|
||||
height: 420px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #eff1f7;
|
||||
background: #fff;
|
||||
flex: 0 0 260px;
|
||||
|
||||
&__title {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eff1f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
&__item:hover {
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
&__name {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__address {
|
||||
margin-top: 4px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -75,8 +75,8 @@ import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
||||
import { baseUrl } from '@/config/env';
|
||||
import { getUploadHeaders } from '@/utils/upload';
|
||||
|
||||
const UNIFIED_ATTACHMENT_TIP =
|
||||
'支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M';
|
||||
const UNIFIED_ATTACHMENT_TIP_PREFIX =
|
||||
'支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过';
|
||||
|
||||
const MIME_MAP = {
|
||||
jpg: 'image/jpeg',
|
||||
@@ -215,7 +215,7 @@ export default {
|
||||
return this.acceptedList.join(',');
|
||||
},
|
||||
tipText() {
|
||||
return this.tip || UNIFIED_ATTACHMENT_TIP;
|
||||
return this.tip || `${UNIFIED_ATTACHMENT_TIP_PREFIX}${this.maxSize}M`;
|
||||
},
|
||||
uploadingCount() {
|
||||
return this.fileList.filter(file => ['ready', 'uploading'].includes(file.status)).length;
|
||||
|
||||
@@ -43,6 +43,7 @@ import tenantPackage from './views/system/tenantpackage.vue';
|
||||
import tenantDatasource from './views/system/tenantdatasource.vue';
|
||||
// 通用弹窗分组白卡(灰底弹窗内使用,标题在卡内)
|
||||
import sectionCard from './components/section-card/main.vue';
|
||||
import mapSearchResults from './components/map-search-results/main.vue';
|
||||
|
||||
window.$crudCommon = crudCommon;
|
||||
debug();
|
||||
@@ -66,6 +67,7 @@ app.component('formSetting', formSetting);
|
||||
app.component('tenantPackage', tenantPackage);
|
||||
app.component('tenantDatasource', tenantDatasource);
|
||||
app.component('sectionCard', sectionCard);
|
||||
app.component('mapSearchResults', mapSearchResults);
|
||||
app.config.globalProperties.$app = app;
|
||||
app.config.globalProperties.$dayjs = dayjs;
|
||||
app.config.globalProperties.website = website;
|
||||
|
||||
@@ -8,7 +8,7 @@ export const transportTypeDict = {
|
||||
},
|
||||
};
|
||||
|
||||
export const carrierTypeOptions = ['承运商', '自运', '网货平台'];
|
||||
export const carrierTypeOptions = ['承运商', '自运'];
|
||||
|
||||
export const loadingStatusOptions = [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
|
||||
@@ -377,6 +377,8 @@ export const config = {
|
||||
title: '运单管理',
|
||||
permission: 'waybill_manage',
|
||||
importUrl: '/blade-transport/waybill-manage/import-waybill-manage',
|
||||
exportUrl: '/blade-transport/waybill-manage/export-waybill-manage',
|
||||
exportName: '运单管理',
|
||||
defaultForm: {
|
||||
carrierType: '承运商',
|
||||
transportType: 'road',
|
||||
@@ -444,8 +446,16 @@ export const config = {
|
||||
return isDriverRejectedWaybill(row);
|
||||
},
|
||||
canEdit(row) {
|
||||
// 进行中的运单不允许编辑
|
||||
if (isInProgressStatus(row, row.businessStatusName)) {
|
||||
return false;
|
||||
}
|
||||
return !isDriverRejectedWaybill(row);
|
||||
},
|
||||
canCancel(row) {
|
||||
// 进行中的运单不允许取消
|
||||
return !isInProgressStatus(row, row.businessStatusName);
|
||||
},
|
||||
deleteStatus: ['draft'],
|
||||
editStatus: ['draft', 'pending'],
|
||||
};
|
||||
@@ -637,7 +647,7 @@ export const option = {
|
||||
viewDisplay: false,
|
||||
},
|
||||
{
|
||||
label: '单价(计价单位)',
|
||||
label: '单价',
|
||||
prop: 'unitPrice',
|
||||
formatter: row => formatUnitPrice(row),
|
||||
minWidth: 140,
|
||||
|
||||
@@ -18,7 +18,7 @@ export const transportReconciliationTableColumns = [
|
||||
export const internalColumns = [
|
||||
{ prop: 'documentNo', label: '单据号', minWidth: 150 },
|
||||
{ prop: 'waybillNo', label: '运单号', minWidth: 120 },
|
||||
{ prop: 'matchedExternalLineNo', label: '匹配外部账单行号', minWidth: 140 },
|
||||
{ prop: 'matchedExternalLineNo', label: '匹配外部账单行号', minWidth: 180 },
|
||||
{ prop: 'vehicleNo', label: '车号', minWidth: 100 },
|
||||
{ prop: 'departureAddress', label: '发货地址', minWidth: 180 },
|
||||
{ prop: 'arrivalAddress', label: '到货地址', minWidth: 180 },
|
||||
@@ -66,9 +66,9 @@ export const externalCargoColumns = [
|
||||
{ prop: 'cargoType', label: '货物类型', minWidth: 120 },
|
||||
{ prop: 'specification', label: '规格', minWidth: 100 },
|
||||
{ prop: 'model', label: '型号', minWidth: 100 },
|
||||
{ prop: 'transportQuantity', label: '运输量', minWidth: 100, number: true },
|
||||
{ prop: 'transportQuantity', label: '运输总量', minWidth: 100, number: true },
|
||||
{ prop: 'unitPrice', label: '运输单价', minWidth: 110, money: true },
|
||||
{ prop: 'mileage', label: '里程(KM)', minWidth: 120, number: true },
|
||||
{ prop: 'freightAmount', label: '运输费', minWidth: 110, money: true },
|
||||
{ prop: 'settlementAmount', label: '结算金额', minWidth: 120, money: true },
|
||||
{ prop: 'settlementAmount', label: '结算费用合计', minWidth: 130, money: true },
|
||||
];
|
||||
|
||||
@@ -131,6 +131,18 @@ export default [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/voucher-manage/detail',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: '执行凭证批次详情',
|
||||
meta: { keepAlive: false, activeMenu: '/business/voucher-manage' },
|
||||
component: () => import('@/views/business/voucher-manage-detail.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/contract-manage/form',
|
||||
component: Layout,
|
||||
@@ -167,6 +179,18 @@ export default [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/waybill-manage/detail',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: '运单管理详情',
|
||||
meta: { keepAlive: false, activeMenu: '/business/waybill-manage' },
|
||||
component: () => import('@/views/business/waybill-manage-detail.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/waybill-import',
|
||||
component: Layout,
|
||||
@@ -215,6 +239,30 @@ export default [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/transport-plan/import',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: '导入运输计划',
|
||||
meta: { keepAlive: false, activeMenu: '/business/transport-plan' },
|
||||
component: () => import('@/views/business/transport-plan-import.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/transport-plan-dispatch',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: '计划调度',
|
||||
meta: { keepAlive: false, activeMenu: '/business/transport-plan' },
|
||||
component: () => import('@/views/business/transport-plan-dispatch.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/business/shipping-template/form',
|
||||
component: Layout,
|
||||
|
||||
@@ -149,3 +149,20 @@ a {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.map-picker-content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
> [class$='__map'],
|
||||
> .address-map {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
> .amap-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,10 @@
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div ref="amap" class="common-address-page__map"></div>
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="mapSearchResults" @select="selectMapSearchResult" />
|
||||
<div ref="amap" class="common-address-page__map"></div>
|
||||
</div>
|
||||
<div class="common-address-page__map-info">
|
||||
<span>{{ mapStatus }}</span>
|
||||
<span v-if="mapSelected.regionName">行政区划:{{ mapSelected.regionName }}</span>
|
||||
@@ -298,6 +301,7 @@ export default {
|
||||
mapKeyword: '',
|
||||
mapStatus: '可搜索地址或点击地图选点',
|
||||
mapSelected: {},
|
||||
mapSearchResults: [],
|
||||
amap: null,
|
||||
amapMarker: null,
|
||||
amapGeocoder: null,
|
||||
@@ -1113,6 +1117,12 @@ export default {
|
||||
this.ensureAmapGeocoder()
|
||||
.then(() => this.runAmapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.mapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveMapPoint(result);
|
||||
if (!point) {
|
||||
this.mapStatus = '未找到匹配地址';
|
||||
@@ -1133,6 +1143,11 @@ export default {
|
||||
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
|
||||
});
|
||||
},
|
||||
selectMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickMapPoint(lnglat, keyword) {
|
||||
const longitude = this.getPointLng(lnglat);
|
||||
const latitude = this.getPointLat(lnglat);
|
||||
|
||||
@@ -288,7 +288,10 @@
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div ref="routeAmap" class="common-route-page__map"></div>
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="routeMapSearchResults" @select="selectRouteMapSearchResult" />
|
||||
<div ref="routeAmap" class="common-route-page__map"></div>
|
||||
</div>
|
||||
<div class="common-route-page__map-info">
|
||||
<span>{{ routeMapStatus }}</span>
|
||||
<span v-if="routeMapSelected.regionName"
|
||||
@@ -359,6 +362,7 @@ export default {
|
||||
routeMapKeyword: '',
|
||||
routeMapStatus: '可搜索地址或点击地图选点',
|
||||
routeMapSelected: {},
|
||||
routeMapSearchResults: [],
|
||||
routeAmap: null,
|
||||
routeAmapMarker: null,
|
||||
routeAmapGeocoder: null,
|
||||
@@ -612,6 +616,12 @@ export default {
|
||||
})
|
||||
.then(() => this.runAmapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.routeMapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveMapPoint(result);
|
||||
if (!point) {
|
||||
this.routeMapStatus = '未找到匹配地址';
|
||||
@@ -628,6 +638,11 @@ export default {
|
||||
this.routeMapLoading = false;
|
||||
});
|
||||
},
|
||||
selectRouteMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickRouteMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickRouteMapPoint(lnglat, keyword) {
|
||||
const longitude = this.getPointLng(lnglat);
|
||||
const latitude = this.getPointLat(lnglat);
|
||||
|
||||
@@ -2047,7 +2047,7 @@
|
||||
v-model="attachmentRows"
|
||||
:readonly="dialogReadonly"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleAttachmentChange"
|
||||
@@ -4265,7 +4265,7 @@
|
||||
<vehicle-attachment-upload
|
||||
v-model="dispatchItemAttachmentRows"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleDispatchItemAttachmentChange"
|
||||
@@ -4965,7 +4965,10 @@
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div ref="transportAmap" class="business-crud-page__map"></div>
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
|
||||
<div ref="transportAmap" class="business-crud-page__map"></div>
|
||||
</div>
|
||||
<div class="business-crud-page__map-info">
|
||||
<span>{{ transportMapStatus }}</span>
|
||||
<span v-if="transportMapSelected.regionName"
|
||||
@@ -5500,7 +5503,7 @@ const defaultDispatchRow = () => ({
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const taskCarrierTypes = ['承运商', '自运', '网货平台'];
|
||||
const taskCarrierTypes = ['承运商', '自运'];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -5735,6 +5738,7 @@ export default {
|
||||
transportMapKeyword: '',
|
||||
transportMapStatus: '可搜索地址或点击地图选点',
|
||||
transportMapSelected: {},
|
||||
transportMapSearchResults: [],
|
||||
transportAmap: null,
|
||||
transportAmapMarker: null,
|
||||
transportAmapGeocoder: null,
|
||||
@@ -6094,7 +6098,7 @@ export default {
|
||||
return this.taskCarrierTypes;
|
||||
},
|
||||
taskCarrierLabel() {
|
||||
return this.form.carrierType === '网货平台' ? '承运方' : '承运商';
|
||||
return '承运商';
|
||||
},
|
||||
taskCarrierValue: {
|
||||
get() {
|
||||
@@ -6258,7 +6262,7 @@ export default {
|
||||
return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType);
|
||||
},
|
||||
dispatchCarrierLabel() {
|
||||
return this.dispatchItemForm.carrierType === '网货平台' ? '承运方' : '承运商';
|
||||
return '承运商';
|
||||
},
|
||||
dispatchCarrierSelection: {
|
||||
get() {
|
||||
@@ -7155,7 +7159,7 @@ export default {
|
||||
this.$message.info('当前运单暂无详情数据');
|
||||
return;
|
||||
}
|
||||
this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } });
|
||||
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: row.id } });
|
||||
},
|
||||
transportPlanWaybillIndex(index) {
|
||||
return (
|
||||
@@ -8793,7 +8797,7 @@ export default {
|
||||
}
|
||||
},
|
||||
isTaskCarrierRequired(carrierType) {
|
||||
return ['承运商', '网货平台'].includes(String(carrierType || '').trim());
|
||||
return String(carrierType || '').trim() === '承运商';
|
||||
},
|
||||
resetWaybillCarrierContractState() {
|
||||
if (!this.isWaybillDetailLayout) return;
|
||||
@@ -9267,7 +9271,7 @@ export default {
|
||||
['vehicleNo', this.transportVehicleNoLabel],
|
||||
];
|
||||
if (this.isTaskCarrierRequired(row.carrierType)) {
|
||||
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']);
|
||||
requiredFields.push(['carrierName', '承运商']);
|
||||
}
|
||||
if (this.isWaybillDetailLayout && row.carrierType === '承运商') {
|
||||
requiredFields.push(['carrierContractId', '承运商合同']);
|
||||
@@ -10106,6 +10110,12 @@ export default {
|
||||
this.ensureTransportAmapGeocoder()
|
||||
.then(() => this.runAmapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.transportMapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveMapPoint(result);
|
||||
if (!point) {
|
||||
this.transportMapStatus = '未找到匹配地址';
|
||||
@@ -10126,6 +10136,11 @@ export default {
|
||||
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
|
||||
});
|
||||
},
|
||||
selectTransportMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickTransportMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickTransportMapPoint(lnglat, keyword) {
|
||||
const longitude = this.getPointLng(lnglat);
|
||||
const latitude = this.getPointLat(lnglat);
|
||||
@@ -12671,7 +12686,7 @@ export default {
|
||||
this.loadDispatchCarrierOptions(this.dispatchRow);
|
||||
},
|
||||
isDispatchCarrierRequired(carrierType) {
|
||||
return ['承运商', '网货平台'].includes(String(carrierType || '').trim());
|
||||
return String(carrierType || '').trim() === '承运商';
|
||||
},
|
||||
getProjectCarrierOptions(project = {}) {
|
||||
const rows = this.parseJsonArray(project.carrierJson);
|
||||
@@ -13058,7 +13073,7 @@ export default {
|
||||
],
|
||||
];
|
||||
if (this.isDispatchCarrierRequired(row.carrierType)) {
|
||||
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']);
|
||||
requiredFields.push(['carrierName', '承运商']);
|
||||
}
|
||||
if (row.carrierType === '承运商') {
|
||||
requiredFields.push(['carrierContractId', '承运商合同']);
|
||||
|
||||
@@ -144,7 +144,9 @@ export default {
|
||||
: [row.captainName, vehicle];
|
||||
return values.filter(Boolean).join(' / ') || '-';
|
||||
},
|
||||
openWaybill(row) { this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } }); },
|
||||
openWaybill(row) {
|
||||
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: row.id } });
|
||||
},
|
||||
openPlan(row) { this.$router.push({ path: '/business/transport-plan', query: { detailId: row.id } }); },
|
||||
waybillStatusName(value) { return ({ pending: '待执行', waiting: '待执行', running: '进行中', processing: '进行中', completed: '已完成', cancelled: '已取消' })[value] || value || '-'; },
|
||||
waybillStatusType(value) { return ({ pending: 'info', waiting: 'info', running: 'warning', processing: 'warning', completed: 'success', cancelled: 'danger' })[value] || 'info'; },
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
<template v-if="route.documentType === '运单'">
|
||||
<div class="goods-heading"><h3>任务信息</h3></div>
|
||||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-type-form"><el-form-item label="承运类型" required><el-radio-group v-model="route.carrierType" @change="value => handleCarrierTypeChange(route, value)"><el-radio-button label="承运商" /><el-radio-button label="自运" /><el-radio-button label="网货平台" /></el-radio-group></el-form-item></el-form>
|
||||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-type-form"><el-form-item label="承运类型" required><el-radio-group v-model="route.carrierType" @change="value => handleCarrierTypeChange(route, value)"><el-radio-button label="承运商" /><el-radio-button label="自运" /></el-radio-group></el-form-item></el-form>
|
||||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-form">
|
||||
<el-row :gutter="16">
|
||||
<template v-if="isRoad(route)">
|
||||
@@ -862,7 +862,7 @@ export default {
|
||||
if (route.documentType === '运单') {
|
||||
if (this.isRoad(route)) {
|
||||
if (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName || !route.vehicleNo)) return this.$message.warning('请选择承运商并填写车牌号');
|
||||
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo)) return this.$message.warning('请补全自运或网货平台的车辆与人员信息');
|
||||
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo)) return this.$message.warning('请补全自运的车辆与人员信息');
|
||||
} else if (!route.vehicleNo || (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName))) {
|
||||
return this.$message.warning('请补全非公路运输的承运信息');
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<div class="goods-heading">
|
||||
<h3>货物信息</h3>
|
||||
<div class="goods-toolbar">
|
||||
<el-link type="primary" @click="cargoImportVisible = true">导入货物</el-link>
|
||||
<!-- <el-link type="primary" @click="cargoImportVisible = true">导入货物</el-link> -->
|
||||
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +283,7 @@
|
||||
<vehicle-attachment-upload
|
||||
v-model="attachmentRows"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleAttachmentChange"
|
||||
@@ -401,7 +401,10 @@
|
||||
>搜索</el-button
|
||||
>
|
||||
</div>
|
||||
<div ref="addressMap" class="address-map" />
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="mapSearchResults" @select="selectAddressMapSearchResult" />
|
||||
<div ref="addressMap" class="address-map" />
|
||||
</div>
|
||||
<div class="address-map-status">
|
||||
{{ mapStatus
|
||||
}}<span v-if="mapSelected.regionName"> 行政区划:{{ mapSelected.regionName }}</span>
|
||||
@@ -639,6 +642,7 @@ export default {
|
||||
mapKeyword: '',
|
||||
mapStatus: '可搜索地址或点击地图选点',
|
||||
mapSelected: {},
|
||||
mapSearchResults: [],
|
||||
addressMapInstance: null,
|
||||
addressMapMarker: null,
|
||||
addressMapGeocoder: null,
|
||||
@@ -1281,12 +1285,24 @@ export default {
|
||||
)
|
||||
)
|
||||
.then(result => {
|
||||
const point = result?.geocodes?.[0]?.location;
|
||||
const geocodes = result?.geocodes || [];
|
||||
this.mapSearchResults = geocodes.map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || this.mapKeyword,
|
||||
address: item.formattedAddress || this.mapKeyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = geocodes[0]?.location;
|
||||
if (!point) return this.$message.warning('地图搜索无匹配地址');
|
||||
this.pickAddressMap(point, this.mapKeyword);
|
||||
})
|
||||
.catch(() => this.$message.error('地图搜索失败'));
|
||||
},
|
||||
selectAddressMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickAddressMap(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
ensureAddressGeocoder() {
|
||||
if (this.addressMapGeocoder) return Promise.resolve(this.addressMapGeocoder);
|
||||
return new Promise(resolve =>
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
>
|
||||
<span>货物信息</span>
|
||||
<div v-if="!dialogReadonly" class="shipping-template-page__section-actions">
|
||||
<el-link type="primary" @click="cargoImportBox = true">导入货物</el-link>
|
||||
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
|
||||
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +233,10 @@
|
||||
<div class="shipping-template-page__cargo-wrap">
|
||||
<el-table :data="transportCargoRows" border empty-text="暂无货物信息">
|
||||
<el-table-column type="index" label="序号" width="70" align="center" />
|
||||
<el-table-column label="货物类型" min-width="200" align="center">
|
||||
<el-table-column min-width="200" align="center">
|
||||
<template #header>
|
||||
<span>货物类型<span class="goods-required-mark">*</span></span>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-cascader
|
||||
v-model="row.cargoTypePath"
|
||||
@@ -482,7 +485,7 @@
|
||||
v-model="attachmentRows"
|
||||
:readonly="dialogReadonly"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleAttachmentChange"
|
||||
@@ -925,7 +928,13 @@
|
||||
@keyup.enter="searchTransportMapKeyword"
|
||||
/><el-button type="primary" @click="searchTransportMapKeyword">搜索</el-button>
|
||||
</div>
|
||||
<div ref="transportMap" class="shipping-template-page__map" />
|
||||
<div class="map-picker-content">
|
||||
<map-search-results
|
||||
:results="transportMapSearchResults"
|
||||
@select="selectTransportMapSearchResult"
|
||||
/>
|
||||
<div ref="transportMap" class="shipping-template-page__map" />
|
||||
</div>
|
||||
<div class="shipping-template-page__map-info">{{ transportMapStatus }}</div>
|
||||
<template #footer
|
||||
><el-button @click="transportMapBox = false">取消</el-button
|
||||
@@ -946,12 +955,45 @@
|
||||
width="1200px"
|
||||
@opened="loadCommonCargoList"
|
||||
>
|
||||
<el-form :model="commonCargoQuery" inline
|
||||
><el-form-item label="货物名称"
|
||||
><el-input v-model="commonCargoQuery.cargoName" clearable /></el-form-item
|
||||
><el-button type="primary" @click="handleCommonCargoSearch">查询</el-button
|
||||
><el-button @click="handleCommonCargoReset">重置</el-button></el-form
|
||||
>
|
||||
<div class="shipping-template-page__dialog-search">
|
||||
<el-form :model="commonCargoQuery" label-position="right" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="货物名称">
|
||||
<el-input v-model="commonCargoQuery.cargoName" clearable placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="货物类型">
|
||||
<el-cascader
|
||||
v-model="commonCargoQuery.cargoTypePath"
|
||||
:options="cargoTypeOptions"
|
||||
:props="cargoTypeCascaderProps"
|
||||
placeholder="请选择"
|
||||
clearable
|
||||
filterable
|
||||
:loading="cargoTypeLoading"
|
||||
@visible-change="visible => visible && ensureCargoTypeOptions()"
|
||||
@change="handleCommonCargoTypeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="规格型号">
|
||||
<el-input
|
||||
v-model="commonCargoQuery.specificationModel"
|
||||
clearable
|
||||
placeholder="请输入"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6" class="shipping-template-page__dialog-search-actions">
|
||||
<el-button type="primary" @click="handleCommonCargoSearch">查询</el-button>
|
||||
<el-button @click="handleCommonCargoReset">重置</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="commonCargoRows"
|
||||
border
|
||||
@@ -1001,35 +1043,6 @@
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
title="导入货物"
|
||||
v-model="cargoImportBox"
|
||||
append-to-body
|
||||
width="560px"
|
||||
@closed="cargoImportRows = []"
|
||||
>
|
||||
<el-upload
|
||||
action="#"
|
||||
accept=".xls,.xlsx"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:disabled="cargoImportLoading"
|
||||
:on-change="handleCargoImportFileChange"
|
||||
><el-button type="primary" :loading="cargoImportLoading">上传</el-button></el-upload
|
||||
>
|
||||
<el-link type="primary" @click="handleCargoImportTemplate">下载模板</el-link>
|
||||
<template #footer
|
||||
><el-button @click="closeCargoImportDialog">关闭</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="cargoImportLoading"
|
||||
:disabled="!cargoImportRows.length"
|
||||
@click="confirmCargoImport"
|
||||
>确定</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="shippingTemplateProcessConfigBox"
|
||||
title="过程配置"
|
||||
@@ -1066,7 +1079,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { exportBlob, importBlob } from '@/api/common';
|
||||
import { exportBlob } from '@/api/common';
|
||||
import { getList as getCommonAddressList } from '@/api/base/common-address';
|
||||
import { getList as getAirportMasterList } from '@/api/base/airport-master';
|
||||
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
|
||||
@@ -1320,6 +1333,7 @@ export default {
|
||||
transportMapLoading: false,
|
||||
transportMapStatus: '可搜索地址或点击地图选点',
|
||||
transportMapSelected: {},
|
||||
transportMapSearchResults: [],
|
||||
transportMapInstance: null,
|
||||
transportMapMarker: null,
|
||||
transportMapGeocoder: null,
|
||||
@@ -1329,9 +1343,6 @@ export default {
|
||||
commonCargoRows: [],
|
||||
commonCargoSelected: [],
|
||||
commonCargoPage: { pageSize: 10, currentPage: 1, total: 0 },
|
||||
cargoImportBox: false,
|
||||
cargoImportLoading: false,
|
||||
cargoImportRows: [],
|
||||
shippingTemplateProcessConfigBox: false,
|
||||
shippingTemplateProcessConfigLoading: false,
|
||||
shippingTemplateProcessConfigRows: [],
|
||||
@@ -1780,6 +1791,9 @@ export default {
|
||||
this.normalizeCargo(item)
|
||||
);
|
||||
if (!this.transportCargoRows.length) this.transportCargoRows = [defaultCargo()];
|
||||
this.restoreCargoTypePaths();
|
||||
// 编辑时先加载货物类型树,保证已有货物类型可以回显为级联路径。
|
||||
this.ensureCargoTypeOptions().catch(() => {});
|
||||
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
|
||||
this.shippingTemplateFreight = {
|
||||
...defaultFreight(),
|
||||
@@ -1846,7 +1860,11 @@ export default {
|
||||
return false;
|
||||
}
|
||||
for (const [index, row] of this.transportCargoRows.entries()) {
|
||||
if (!row.cargoType || !row.cargoName || !row.quantity || !row.quantityUnit) {
|
||||
if (!String(row.cargoType || '').trim() && !this.cargoPath(row.cargoTypePath).length) {
|
||||
this.$message.warning(`第${index + 1}行货物类型不能为空`);
|
||||
return false;
|
||||
}
|
||||
if (!String(row.cargoName || '').trim() || !row.quantity || !row.quantityUnit) {
|
||||
this.$message.warning(`第${index + 1}行货物信息不完整`);
|
||||
return false;
|
||||
}
|
||||
@@ -2339,6 +2357,7 @@ export default {
|
||||
regionName: this.form[`${prefix}Name`],
|
||||
addressCode: this.form[`${prefix}AddressCode`],
|
||||
});
|
||||
this.transportMapSearchResults = [];
|
||||
this.transportMapStatus = this.transportMapSelected.longitude
|
||||
? '已加载当前选点,可重新选点'
|
||||
: '可搜索地址或点击地图选点';
|
||||
@@ -2403,9 +2422,18 @@ export default {
|
||||
.then(() => this.ensureTransportMapGeocoder())
|
||||
.then(() => this.runTransportMapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.transportMapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveTransportMapPoint(result);
|
||||
if (!point) throw new Error('未找到匹配地址');
|
||||
return this.pickMapPoint(point, keyword);
|
||||
return this.$nextTick().then(() => {
|
||||
this.transportMapInstance?.resize();
|
||||
return this.pickMapPoint(point, keyword);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.transportMapStatus = '地图搜索失败';
|
||||
@@ -2415,6 +2443,11 @@ export default {
|
||||
this.transportMapLoading = false;
|
||||
});
|
||||
},
|
||||
selectTransportMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickMapPoint(point, keyword = '') {
|
||||
const longitude = this.getTransportMapPointLng(point);
|
||||
const latitude = this.getTransportMapPointLat(point);
|
||||
@@ -2550,6 +2583,7 @@ export default {
|
||||
.then(res => {
|
||||
this.cargoTypeOptions = this.buildCargoTree(extractRecords(res));
|
||||
this.cargoTypeFlatOptions = this.flattenCargo(this.cargoTypeOptions);
|
||||
this.restoreCargoTypePaths();
|
||||
return this.cargoTypeOptions;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -2603,13 +2637,81 @@ export default {
|
||||
cargoPath(path) {
|
||||
return Array.isArray(path) ? path.slice(0, 2).map(String) : [];
|
||||
},
|
||||
findCargoTypeOption(row = {}) {
|
||||
const path = this.cargoPath(row.cargoTypePath);
|
||||
if (path.length) {
|
||||
const pathKey = path.join('/');
|
||||
const pathMatch = this.cargoTypeFlatOptions.find(item => item.path?.join('/') === pathKey);
|
||||
if (pathMatch) return pathMatch;
|
||||
}
|
||||
const codeCandidates = [row.secondCargoTypeCode, row.cargoTypeCode, row.firstCargoTypeCode]
|
||||
.map(value => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const nameCandidates = [
|
||||
row.secondCargoTypeName,
|
||||
row.cargoType,
|
||||
row.goodsType,
|
||||
row.firstCargoTypeName,
|
||||
]
|
||||
.map(value => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
this.cargoTypeFlatOptions.find(item =>
|
||||
codeCandidates.some(
|
||||
code => code === String(item.cargoCode || item.code || item.id || '').trim()
|
||||
)
|
||||
) ||
|
||||
this.cargoTypeFlatOptions.find(item =>
|
||||
nameCandidates.some(name => name === String(item.cargoName || '').trim())
|
||||
)
|
||||
);
|
||||
},
|
||||
resolveCargoTypePath(row = {}) {
|
||||
const matched = this.findCargoTypeOption(row);
|
||||
return matched?.path ? this.cargoPath(matched.path) : [];
|
||||
},
|
||||
restoreCargoTypePaths() {
|
||||
if (!this.transportCargoRows.length || !this.cargoTypeFlatOptions.length) return;
|
||||
this.transportCargoRows.forEach(row => {
|
||||
const path = this.resolveCargoTypePath(row);
|
||||
if (!path.length) return;
|
||||
const item = this.cargoTypeFlatOptions.find(
|
||||
option => option.path?.join('/') === path.join('/')
|
||||
);
|
||||
row.cargoTypePath = path;
|
||||
row.cargoType =
|
||||
item?.cargoName ||
|
||||
row.cargoType ||
|
||||
row.secondCargoTypeName ||
|
||||
row.firstCargoTypeName ||
|
||||
'';
|
||||
row.cargoTypeCode =
|
||||
item?.cargoCode ||
|
||||
row.cargoTypeCode ||
|
||||
row.secondCargoTypeCode ||
|
||||
row.firstCargoTypeCode ||
|
||||
'';
|
||||
});
|
||||
},
|
||||
normalizeCargo(row = {}) {
|
||||
const cargoTypePath = this.resolveCargoTypePath(row);
|
||||
const cargoType = this.findCargoTypeOption({ ...row, cargoTypePath });
|
||||
return {
|
||||
...defaultCargo(),
|
||||
...row,
|
||||
cargoTypePath: this.cargoPath(row.cargoTypePath),
|
||||
cargoType: row.cargoType || row.secondCargoTypeName || row.firstCargoTypeName || '',
|
||||
cargoTypeCode: row.cargoTypeCode || row.secondCargoTypeCode || row.firstCargoTypeCode || '',
|
||||
cargoTypePath,
|
||||
cargoType:
|
||||
cargoType?.cargoName ||
|
||||
row.cargoType ||
|
||||
row.secondCargoTypeName ||
|
||||
row.firstCargoTypeName ||
|
||||
'',
|
||||
cargoTypeCode:
|
||||
cargoType?.cargoCode ||
|
||||
row.cargoTypeCode ||
|
||||
row.secondCargoTypeCode ||
|
||||
row.firstCargoTypeCode ||
|
||||
'',
|
||||
cargoName: row.cargoName || row.goodsName || '',
|
||||
};
|
||||
},
|
||||
@@ -2623,7 +2725,7 @@ export default {
|
||||
.filter(Boolean);
|
||||
row.cargoTypePath = path;
|
||||
row.cargoType = labels.at(-1) || '';
|
||||
row.cargoTypeCode = item?.cargoCode || '';
|
||||
row.cargoTypeCode = item?.cargoCode || item?.code || item?.id || '';
|
||||
row.cargoName = '';
|
||||
},
|
||||
fetchCargoSuggestions(query, callback, row) {
|
||||
@@ -2637,8 +2739,28 @@ export default {
|
||||
)
|
||||
.catch(() => callback([]));
|
||||
},
|
||||
selectCargoSuggestion(row, item) {
|
||||
Object.assign(row, this.normalizeCargo(item));
|
||||
async selectCargoSuggestion(row, item = {}) {
|
||||
const cargoTypeOption = this.findCargoTypeOption(item);
|
||||
const selectedCargo = {
|
||||
...row,
|
||||
...item,
|
||||
cargoName: item.cargoName || item.name || item.label || item.value || row.cargoName || '',
|
||||
};
|
||||
try {
|
||||
await this.ensureCargoTypeOptions();
|
||||
} catch (error) {
|
||||
// 货物类型树加载失败时仍保留货物名称,提交时会给出货物类型必填提示。
|
||||
}
|
||||
// 货物名称返回了类型信息时,以货物自身的类型覆盖当前行原有类型。
|
||||
// 这样从一个类型切换到另一个类型时,级联选择器不会继续显示旧值。
|
||||
const matchedCargoType = this.findCargoTypeOption(item) || cargoTypeOption;
|
||||
if (matchedCargoType?.path?.length) {
|
||||
selectedCargo.cargoTypePath = this.cargoPath(matchedCargoType.path);
|
||||
selectedCargo.cargoType = matchedCargoType.cargoName || selectedCargo.cargoType;
|
||||
selectedCargo.cargoTypeCode =
|
||||
matchedCargoType.cargoCode || matchedCargoType.code || matchedCargoType.id || '';
|
||||
}
|
||||
Object.assign(row, this.normalizeCargo(selectedCargo));
|
||||
row.value = undefined;
|
||||
this.syncFreightItems();
|
||||
},
|
||||
@@ -2786,13 +2908,16 @@ export default {
|
||||
},
|
||||
openCommonCargoDialog() {
|
||||
this.commonCargoSelected = [];
|
||||
this.commonCargoQuery = {};
|
||||
this.commonCargoPage.currentPage = 1;
|
||||
this.commonCargoBox = true;
|
||||
this.ensureCargoTypeOptions().catch(() => {});
|
||||
},
|
||||
loadCommonCargoList() {
|
||||
this.commonCargoLoading = true;
|
||||
const { cargoTypePath, ...query } = this.commonCargoQuery || {};
|
||||
getCommonCargoList(this.commonCargoPage.currentPage, this.commonCargoPage.pageSize, {
|
||||
...this.normalizeSearch(this.commonCargoQuery),
|
||||
...this.normalizeSearch(query),
|
||||
allDept: 0,
|
||||
})
|
||||
.then(res => {
|
||||
@@ -2812,6 +2937,29 @@ export default {
|
||||
this.commonCargoQuery = {};
|
||||
this.handleCommonCargoSearch();
|
||||
},
|
||||
handleCommonCargoTypeChange(value) {
|
||||
const path = this.cargoPath(value);
|
||||
const firstCargoType = this.cargoTypeFlatOptions.find(
|
||||
item => item.path?.join('/') === path.slice(0, 1).join('/')
|
||||
);
|
||||
const secondCargoType = this.cargoTypeFlatOptions.find(
|
||||
item => item.path?.join('/') === path.join('/')
|
||||
);
|
||||
this.commonCargoQuery = {
|
||||
...this.commonCargoQuery,
|
||||
cargoTypePath: path,
|
||||
firstCargoTypeName: firstCargoType?.cargoName || '',
|
||||
firstCargoTypeCode: firstCargoType?.cargoCode || '',
|
||||
secondCargoTypeName: path.length > 1 ? secondCargoType?.cargoName || '' : '',
|
||||
secondCargoTypeCode: path.length > 1 ? secondCargoType?.cargoCode || '' : '',
|
||||
};
|
||||
if (!path.length) {
|
||||
delete this.commonCargoQuery.firstCargoTypeName;
|
||||
delete this.commonCargoQuery.firstCargoTypeCode;
|
||||
delete this.commonCargoQuery.secondCargoTypeName;
|
||||
delete this.commonCargoQuery.secondCargoTypeCode;
|
||||
}
|
||||
},
|
||||
handleCommonCargoCurrentChange(page) {
|
||||
this.commonCargoPage.currentPage = page;
|
||||
this.loadCommonCargoList();
|
||||
@@ -2823,54 +2971,16 @@ export default {
|
||||
handleCommonCargoSelectionChange(rows) {
|
||||
this.commonCargoSelected = rows || [];
|
||||
},
|
||||
selectCommonCargoRow(row) {
|
||||
async selectCommonCargoRow(row) {
|
||||
await this.ensureCargoTypeOptions().catch(() => {});
|
||||
this.addCargoRow(-1, row);
|
||||
this.commonCargoBox = false;
|
||||
},
|
||||
confirmCommonCargoSelection() {
|
||||
async confirmCommonCargoSelection() {
|
||||
await this.ensureCargoTypeOptions().catch(() => {});
|
||||
this.commonCargoSelected.forEach(row => this.addCargoRow(-1, row));
|
||||
this.commonCargoBox = false;
|
||||
},
|
||||
handleCargoImportFileChange(file) {
|
||||
const raw = file.raw;
|
||||
if (!raw || !/\.(xls|xlsx)$/i.test(raw.name || ''))
|
||||
return this.$message.warning('请上传 Excel 文件');
|
||||
this.cargoImportLoading = true;
|
||||
importBlob('/blade-transport/shipping-template/import-goods', raw)
|
||||
.then(async res => {
|
||||
const type = res.headers?.['content-type'] || res.data?.type || '';
|
||||
if (type.includes('excel')) {
|
||||
downloadXls(
|
||||
res.data,
|
||||
`发货模板货物导入失败明细${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
|
||||
);
|
||||
this.$message.warning('部分数据导入失败,已下载失败明细');
|
||||
return;
|
||||
}
|
||||
const result = JSON.parse(await res.data.text());
|
||||
if (result.code !== 200) throw new Error(result.msg);
|
||||
this.cargoImportRows = result.data || [];
|
||||
})
|
||||
.catch(() => this.$message.error('货物导入失败,请检查文件格式'))
|
||||
.finally(() => {
|
||||
this.cargoImportLoading = false;
|
||||
});
|
||||
},
|
||||
confirmCargoImport() {
|
||||
this.cargoImportRows.forEach(row => this.addCargoRow(-1, row));
|
||||
this.closeCargoImportDialog();
|
||||
},
|
||||
closeCargoImportDialog() {
|
||||
if (!this.cargoImportLoading) {
|
||||
this.cargoImportBox = false;
|
||||
this.cargoImportRows = [];
|
||||
}
|
||||
},
|
||||
handleCargoImportTemplate() {
|
||||
exportBlob('/blade-transport/shipping-template/export-goods-template').then(res =>
|
||||
downloadXls(res.data, '发货模板货物导入模板.xlsx')
|
||||
);
|
||||
},
|
||||
handleAttachmentChange(list) {
|
||||
const name = this.userInfo?.realName || this.userInfo?.userName || '';
|
||||
const time = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
@@ -3044,6 +3154,10 @@ export default {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.goods-required-mark {
|
||||
margin-left: 2px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
.shipping-template-page__cargo-total {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -3388,10 +3502,27 @@ export default {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.shipping-template-page__dialog-search {
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 12px 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.shipping-template-page__dialog-search-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.shipping-template-page__map {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 480px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
:deep(.shipping-template-page__map > .amap-container) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.shipping-template-page__map-info {
|
||||
margin-top: 8px;
|
||||
color: #606266;
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
>
|
||||
<span>货物信息</span>
|
||||
<div v-if="!dialogReadonly" class="transport-plan-page__section-actions">
|
||||
<el-link type="primary" @click="cargoImportBox = true">导入货物</el-link>
|
||||
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
|
||||
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,7 +526,7 @@
|
||||
v-model="attachmentRows"
|
||||
:readonly="dialogReadonly"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleAttachmentChange"
|
||||
@@ -681,10 +681,8 @@
|
||||
<div class="transport-plan-page__transport-plan-detail-route-point">
|
||||
<b class="is-start">起</b>
|
||||
<div>
|
||||
<strong>{{
|
||||
detailRow.departureName || detailRow.departureAddress || '-'
|
||||
}}</strong>
|
||||
<p>{{ formatTransportPlanProvinceCityDistrict(detailRow.departureAddress) }}</p>
|
||||
<strong>{{ formatTransportPlanDetailRouteName('departure') }}</strong>
|
||||
<p>{{ detailRow.departureAddress || '-' }}</p>
|
||||
<p>
|
||||
{{
|
||||
[detailRow.departureContact, detailRow.departurePhone]
|
||||
@@ -698,8 +696,8 @@
|
||||
<div class="transport-plan-page__transport-plan-detail-route-point">
|
||||
<b class="is-end">终</b>
|
||||
<div>
|
||||
<strong>{{ detailRow.arrivalName || detailRow.arrivalAddress || '-' }}</strong>
|
||||
<p>{{ formatTransportPlanProvinceCityDistrict(detailRow.arrivalAddress) }}</p>
|
||||
<strong>{{ formatTransportPlanDetailRouteName('arrival') }}</strong>
|
||||
<p>{{ detailRow.arrivalAddress || '-' }}</p>
|
||||
<p>
|
||||
{{
|
||||
[detailRow.arrivalContact, detailRow.arrivalPhone]
|
||||
@@ -1558,7 +1556,7 @@
|
||||
>
|
||||
<span>货物信息</span>
|
||||
<div class="transport-plan-page__section-actions">
|
||||
<el-link type="primary" @click="cargoImportBox = true">导入货物</el-link>
|
||||
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
|
||||
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2201,7 +2199,7 @@
|
||||
<vehicle-attachment-upload
|
||||
v-model="dispatchItemAttachmentRows"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleDispatchItemAttachmentChange"
|
||||
@@ -2518,7 +2516,10 @@
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div ref="transportAmap" class="transport-plan-page__map"></div>
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
|
||||
<div ref="transportAmap" class="transport-plan-page__map"></div>
|
||||
</div>
|
||||
<div class="transport-plan-page__map-info">
|
||||
<span>{{ transportMapStatus }}</span>
|
||||
<span v-if="transportMapSelected.regionName"
|
||||
@@ -2689,7 +2690,10 @@ import { getList as getCargoTypeList } from '@/api/base/cargo-type';
|
||||
import { getList as getPortTerminalList } from '@/api/base/port-terminal';
|
||||
import { getList as getRailwayStationList } from '@/api/base/railway-station';
|
||||
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
|
||||
import { getList as getContractList } from '@/api/business/contract-manage';
|
||||
import {
|
||||
getDetail as getContractDetail,
|
||||
getList as getContractList,
|
||||
} from '@/api/business/contract-manage';
|
||||
import {
|
||||
getDetail as getProjectDetail,
|
||||
getList as getProjectList,
|
||||
@@ -2945,7 +2949,7 @@ const defaultDispatchRow = () => ({
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const taskCarrierTypes = ['承运商', '自运', '网货平台'];
|
||||
const taskCarrierTypes = ['承运商', '自运'];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -3121,6 +3125,7 @@ export default {
|
||||
transportMapKeyword: '',
|
||||
transportMapStatus: '可搜索地址或点击地图选点',
|
||||
transportMapSelected: {},
|
||||
transportMapSearchResults: [],
|
||||
transportAmap: null,
|
||||
transportAmapMarker: null,
|
||||
transportAmapGeocoder: null,
|
||||
@@ -3446,7 +3451,7 @@ export default {
|
||||
return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType);
|
||||
},
|
||||
dispatchCarrierLabel() {
|
||||
return this.dispatchItemForm.carrierType === '网货平台' ? '承运方' : '承运商';
|
||||
return '承运商';
|
||||
},
|
||||
dispatchCarrierSelection: {
|
||||
get() {
|
||||
@@ -3790,6 +3795,34 @@ export default {
|
||||
const cityMatch = text.match(/市/);
|
||||
return cityMatch ? text.slice(0, cityMatch.index + 1) : text;
|
||||
},
|
||||
formatTransportPlanRoadCityDistrict(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return '-';
|
||||
const cityMatch = text.match(/市/);
|
||||
if (!cityMatch) return this.formatTransportPlanProvinceCityDistrict(text);
|
||||
|
||||
const cityEnd = cityMatch.index + 1;
|
||||
const cityWithProvince = text.slice(0, cityEnd);
|
||||
const city =
|
||||
cityWithProvince.match(/(?:省|自治区|特别行政区)([^省]+市)$/)?.[1] || cityWithProvince;
|
||||
const districtText = text.slice(cityEnd);
|
||||
const districtMatch = districtText.match(
|
||||
/^(?:.*(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/
|
||||
);
|
||||
if (!districtMatch) return city;
|
||||
|
||||
const district = districtMatch[0];
|
||||
return district ? `${city} ${district}` : city;
|
||||
},
|
||||
formatTransportPlanDetailRouteName(type) {
|
||||
const nameProp = type === 'departure' ? 'departureName' : 'arrivalName';
|
||||
const addressProp = type === 'departure' ? 'departureAddress' : 'arrivalAddress';
|
||||
const name = this.detailRow[nameProp] || this.detailRow[addressProp];
|
||||
const transportMode = this.resolveTransportMode(this.detailRow.transportType);
|
||||
return transportMode === 'road'
|
||||
? this.formatTransportPlanRoadCityDistrict(name)
|
||||
: name || '-';
|
||||
},
|
||||
hasPermission(code) {
|
||||
return this.isAdmin || this.validData(this.permission && this.permission[code], false);
|
||||
},
|
||||
@@ -3988,7 +4021,7 @@ export default {
|
||||
this.$message.info('当前运单暂无详情数据');
|
||||
return;
|
||||
}
|
||||
this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } });
|
||||
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: row.id } });
|
||||
},
|
||||
transportPlanWaybillIndex(index) {
|
||||
return (
|
||||
@@ -4054,10 +4087,28 @@ export default {
|
||||
request
|
||||
.then(res => {
|
||||
const detail = res?.data?.data || res?.data || row;
|
||||
this.detailRow = detail;
|
||||
this.ensureTransportPlanDetailTransportTypeName(detail);
|
||||
this.applyFormDetail(detail);
|
||||
return null;
|
||||
if (!detail.contractId) {
|
||||
this.detailRow = detail;
|
||||
this.ensureTransportPlanDetailTransportTypeName(detail);
|
||||
this.applyFormDetail(detail);
|
||||
return null;
|
||||
}
|
||||
return getContractDetail(detail.contractId)
|
||||
.then(contractRes => {
|
||||
const contract = contractRes?.data?.data || contractRes?.data || {};
|
||||
this.detailRow = {
|
||||
...detail,
|
||||
customerName:
|
||||
contract.partyA || contract.customerName || detail.customerName || '',
|
||||
};
|
||||
this.ensureTransportPlanDetailTransportTypeName(this.detailRow);
|
||||
this.applyFormDetail(this.detailRow);
|
||||
})
|
||||
.catch(() => {
|
||||
this.detailRow = detail;
|
||||
this.ensureTransportPlanDetailTransportTypeName(detail);
|
||||
this.applyFormDetail(detail);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false;
|
||||
@@ -4610,7 +4661,11 @@ export default {
|
||||
this.form.settlementCurrency =
|
||||
contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || '';
|
||||
this.form.customerName =
|
||||
contract.customerName || contract.customerNames || this.form.customerName || '';
|
||||
contract.partyA ||
|
||||
contract.customerName ||
|
||||
contract.customerNames ||
|
||||
this.form.customerName ||
|
||||
'';
|
||||
this.$refs.crud?.validateField?.('contractName');
|
||||
},
|
||||
loadDispatchContractCurrency(plan = {}) {
|
||||
@@ -5505,6 +5560,12 @@ export default {
|
||||
this.ensureTransportAmapGeocoder()
|
||||
.then(() => this.runAmapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.transportMapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveMapPoint(result);
|
||||
if (!point) {
|
||||
this.transportMapStatus = '未找到匹配地址';
|
||||
@@ -5525,6 +5586,11 @@ export default {
|
||||
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
|
||||
});
|
||||
},
|
||||
selectTransportMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickTransportMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickTransportMapPoint(lnglat, keyword) {
|
||||
const longitude = this.getPointLng(lnglat);
|
||||
const latitude = this.getPointLat(lnglat);
|
||||
@@ -6246,8 +6312,7 @@ export default {
|
||||
},
|
||||
handleImport() {
|
||||
if (this.config.enableTransportPlanImport) {
|
||||
this.transportPlanImportBox = true;
|
||||
this.loadProjectOptions();
|
||||
this.$router.push('/business/transport-plan/import');
|
||||
return;
|
||||
}
|
||||
this.$message.warning('运输计划导入接口未配置');
|
||||
@@ -6637,33 +6702,13 @@ export default {
|
||||
this.$message.warning('运输计划数据异常,无法调度');
|
||||
return;
|
||||
}
|
||||
this.dispatchRow = { ...row };
|
||||
this.dispatchCarrierOptions = [];
|
||||
this.dispatchCarrierContractOptions = [];
|
||||
this.dispatchCarrierRequestId += 1;
|
||||
this.dispatchBox = true;
|
||||
this.dispatchLoading = true;
|
||||
this.dispatchRows = [];
|
||||
this.loadDispatchTransportTypeOptions();
|
||||
const request =
|
||||
this.api && typeof this.api.getDetail === 'function'
|
||||
? this.api.getDetail(row.id)
|
||||
: Promise.resolve({ data: { data: row } });
|
||||
request
|
||||
.then(res => {
|
||||
const detail = {
|
||||
...row,
|
||||
...(res?.data?.data || {}),
|
||||
};
|
||||
this.dispatchRow = detail;
|
||||
this.dispatchRows = this.buildDispatchRows(detail);
|
||||
return this.loadDispatchContractCurrency(detail).then(() => {
|
||||
this.loadDispatchCarrierOptions(detail);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.dispatchLoading = false;
|
||||
});
|
||||
// 跳转到独立的调度页面
|
||||
this.$router.push({
|
||||
path: '/business/transport-plan-dispatch',
|
||||
query: {
|
||||
planId: row.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
loadDispatchTransportTypeOptions() {
|
||||
if (this.dispatchTransportTypeOptions.length || this.dispatchTransportTypeLoading) {
|
||||
@@ -7308,7 +7353,7 @@ export default {
|
||||
this.loadDispatchCarrierOptions(this.dispatchRow);
|
||||
},
|
||||
isDispatchCarrierRequired(carrierType) {
|
||||
return ['承运商', '网货平台'].includes(String(carrierType || '').trim());
|
||||
return String(carrierType || '').trim() === '承运商';
|
||||
},
|
||||
getProjectCarrierOptions(project = {}) {
|
||||
const rows = this.parseJsonArray(project.carrierJson);
|
||||
@@ -7748,7 +7793,7 @@ export default {
|
||||
],
|
||||
];
|
||||
if (this.isDispatchCarrierRequired(row.carrierType)) {
|
||||
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']);
|
||||
requiredFields.push(['carrierName', '承运商']);
|
||||
}
|
||||
if (row.carrierType === '承运商') {
|
||||
requiredFields.push(['carrierContractId', '承运商合同']);
|
||||
|
||||
@@ -119,18 +119,22 @@
|
||||
label="运单批次号"
|
||||
min-width="140"
|
||||
/><el-table-column prop="originalNo" label="原始单号" min-width="130" /><el-table-column
|
||||
prop="loadingIdentifier"
|
||||
label="配载标识号"
|
||||
min-width="130"
|
||||
/><el-table-column
|
||||
prop="vehicleNo"
|
||||
label="车牌号/航班号/船号/班列号"
|
||||
min-width="190"
|
||||
/><el-table-column prop="driverName" label="司机/船长" min-width="120" /><el-table-column
|
||||
prop="transportType"
|
||||
label="运输方式"
|
||||
width="110"
|
||||
/><el-table-column prop="cargoName" label="货物名称" width="130" /><el-table-column
|
||||
prop="cargoType"
|
||||
label="货物类型"
|
||||
width="120"
|
||||
/><el-table-column prop="quantity" label="重量" width="90" /><el-table-column
|
||||
/><el-table-column prop="transportType" label="运输方式" width="110" /><el-table-column
|
||||
prop="driverName"
|
||||
label="司机/船长姓名"
|
||||
min-width="120"
|
||||
/><el-table-column
|
||||
prop="driverPhone"
|
||||
label="司机/船长手机号"
|
||||
width="140"
|
||||
/><el-table-column
|
||||
prop="departureAddress"
|
||||
label="发货地址"
|
||||
min-width="220"
|
||||
@@ -148,11 +152,22 @@
|
||||
prop="arrivalPhone"
|
||||
label="收货联系人电话"
|
||||
width="140"
|
||||
/><el-table-column prop="startDate" label="开始时间" width="160" sortable /><el-table-column
|
||||
prop="endDate"
|
||||
label="结束时间"
|
||||
width="160"
|
||||
sortable
|
||||
/><el-table-column prop="cargoName" label="货物名称" width="130" /><el-table-column
|
||||
prop="cargoType"
|
||||
label="货物类型"
|
||||
width="120"
|
||||
/><el-table-column prop="packageType" label="包装" width="100" /><el-table-column
|
||||
prop="quantity"
|
||||
label="数量"
|
||||
width="90"
|
||||
/><el-table-column prop="quantityUnit" label="数量单位" width="100" /><el-table-column
|
||||
prop="specification"
|
||||
label="规格"
|
||||
width="120"
|
||||
/><el-table-column prop="model" label="型号" width="120" /><el-table-column
|
||||
prop="mileage"
|
||||
label="里程(km)"
|
||||
width="100"
|
||||
/><el-table-column prop="unitPrice" label="单价" width="90" /><el-table-column
|
||||
prop="freight"
|
||||
label="运费"
|
||||
@@ -161,11 +176,31 @@
|
||||
prop="freightTotal"
|
||||
label="运费合计"
|
||||
width="100"
|
||||
/><el-table-column
|
||||
prop="actualStartDate"
|
||||
label="实际发货时间"
|
||||
width="160"
|
||||
sortable
|
||||
/><el-table-column
|
||||
prop="actualEndDate"
|
||||
label="实际完成时间"
|
||||
width="160"
|
||||
sortable
|
||||
/><el-table-column
|
||||
prop="planStartDate"
|
||||
label="预计发货时间"
|
||||
width="160"
|
||||
sortable
|
||||
/><el-table-column
|
||||
prop="planEndDate"
|
||||
label="预计完成时间"
|
||||
width="160"
|
||||
sortable
|
||||
/><el-table-column prop="remark" label="备注" min-width="160" /><el-table-column
|
||||
label="操作"
|
||||
width="80"
|
||||
>-</el-table-column
|
||||
></el-table
|
||||
prop="waybillIdentifier"
|
||||
label="同一运单标识号"
|
||||
min-width="140"
|
||||
/><el-table-column label="操作" width="80">-</el-table-column></el-table
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="detailPage.current"
|
||||
@@ -441,7 +476,6 @@
|
||||
</section>
|
||||
<div v-if="createPage" class="waybill-import-create__footer">
|
||||
<el-button @click="closeCreate">取消</el-button>
|
||||
<el-button @click="saveDraft">保存草稿</el-button>
|
||||
<el-button type="primary" @click="confirmImport">确认导入</el-button>
|
||||
</div>
|
||||
</component>
|
||||
@@ -496,7 +530,7 @@ const createDefaultForm = () => ({
|
||||
const form = reactive(createDefaultForm());
|
||||
// 批量导入状态仅保留草稿与导入完成两种,与后端 importStatus 取值一致。
|
||||
const importStatusOptions = [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '进行中', value: 'draft' },
|
||||
{ label: '导入完成', value: 'completed' },
|
||||
];
|
||||
// 编辑草稿时明细已入库,此时不再强制重新上传附件。
|
||||
@@ -541,7 +575,7 @@ const previewRows = computed(() =>
|
||||
);
|
||||
const carrierContractsLoaded = ref(false),
|
||||
hasCarrierContracts = ref(null);
|
||||
const carrierTypes = ['承运商', '自运', '网货平台'];
|
||||
const carrierTypes = ['承运商', '自运'];
|
||||
const isSelfOperated = computed(() => form.carrierType === '自运');
|
||||
// 项目下没有承运商合同时只允许自运,与运单管理的承运类型收窄规则一致。
|
||||
const carrierTypeOptions = computed(() =>
|
||||
@@ -570,19 +604,47 @@ const resetCreateForm = () => {
|
||||
const detailColumns = [
|
||||
{ prop: 'batchNo', label: '运单批次号', minWidth: 150 },
|
||||
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号', editor: 'input', minWidth: 220 },
|
||||
{ prop: 'driverName', label: '司机/船长', editor: 'driver', minWidth: 170 },
|
||||
{ prop: 'transportType', label: '运输类型', editor: 'transportType', minWidth: 150 },
|
||||
{ prop: 'transportType', label: '运输方式', editor: 'transportType', minWidth: 150 },
|
||||
{ prop: 'driverName', label: '司机/船长姓名', editor: 'driver', minWidth: 170 },
|
||||
{ prop: 'driverPhone', label: '司机/船长手机号', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'departureAddress', label: '发货地址', editor: 'input', minWidth: 220 },
|
||||
{ prop: 'departureContact', label: '发货联系人', editor: 'input', minWidth: 120 },
|
||||
{ prop: 'departurePhone', label: '发货联系人电话', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'arrivalAddress', label: '到货地址', editor: 'input', minWidth: 220 },
|
||||
{ prop: 'arrivalContact', label: '收货联系人', editor: 'input', minWidth: 120 },
|
||||
{ prop: 'arrivalPhone', label: '收货联系人电话', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'cargoName', label: '货物名称', editor: 'cargoName', minWidth: 220 },
|
||||
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', minWidth: 220 },
|
||||
{ prop: 'quantity', label: '重量', editor: 'number', minWidth: 130 },
|
||||
{ prop: 'packageType', label: '包装', editor: 'input', minWidth: 120 },
|
||||
{ prop: 'quantity', label: '数量', editor: 'number', minWidth: 130 },
|
||||
{ prop: 'quantityUnit', label: '数量单位', editor: 'input', minWidth: 120 },
|
||||
{ prop: 'specification', label: '规格', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'model', label: '型号', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'mileage', label: '里程(km)', editor: 'number', minWidth: 120 },
|
||||
{ prop: 'unitPrice', label: '单价', editor: 'number', minWidth: 120 },
|
||||
{ prop: 'freight', label: '运费', editor: 'number', minWidth: 120 },
|
||||
{ prop: 'otherFeeTotal', label: '其他费用合计', editor: 'number', minWidth: 140 },
|
||||
{ prop: 'freightTotal', label: '运费合计', editor: 'number', minWidth: 120 },
|
||||
{ prop: 'actualStartDate', label: '实际发货时间', editor: 'date', minWidth: 160 },
|
||||
{ prop: 'actualEndDate', label: '实际完成时间', editor: 'date', minWidth: 160 },
|
||||
{ prop: 'planStartDate', label: '预计发货时间', editor: 'date', minWidth: 160 },
|
||||
{ prop: 'planEndDate', label: '预计完成时间', editor: 'date', minWidth: 160 },
|
||||
{ prop: 'remark', label: '备注', editor: 'textarea', minWidth: 200 },
|
||||
{ prop: 'waybillIdentifier', label: '同一运单标识号', editor: 'input', minWidth: 150 },
|
||||
];
|
||||
const requiredDetailFields = [
|
||||
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号' },
|
||||
{ prop: 'driverName', label: '司机/船长' },
|
||||
{ prop: 'transportType', label: '运输类型' },
|
||||
{ prop: 'transportType', label: '运输方式' },
|
||||
{ prop: 'departureAddress', label: '发货地址' },
|
||||
{ prop: 'arrivalAddress', label: '到货地址' },
|
||||
{ prop: 'cargoName', label: '货物名称' },
|
||||
{ prop: 'cargoType', label: '货物类型' },
|
||||
{ prop: 'quantity', label: '数量' },
|
||||
{ prop: 'quantityUnit', label: '数量单位' },
|
||||
{ prop: 'actualStartDate', label: '实际发货时间' },
|
||||
{ prop: 'actualEndDate', label: '实际完成时间' },
|
||||
];
|
||||
const projectQueryParams = { approvalStatuses: 'approved,change_approved' };
|
||||
const extractRecords = res => {
|
||||
@@ -975,6 +1037,12 @@ const carrierChange = value => {
|
||||
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
|
||||
};
|
||||
const fileChange = async (file, list) => {
|
||||
if (file.raw?.size > 50 * 1024 * 1024) {
|
||||
ElMessage.warning('单个文件大小不能超过50M');
|
||||
files.value = [];
|
||||
form.file = null;
|
||||
return;
|
||||
}
|
||||
files.value = list.slice(-1);
|
||||
form.file = file.raw;
|
||||
const XLSX = await import('xlsx');
|
||||
@@ -988,25 +1056,35 @@ const fileChange = async (file, list) => {
|
||||
);
|
||||
const keyMap = {
|
||||
originalNo: ['原始单号'],
|
||||
loadingIdentifier: ['配载标识号'],
|
||||
vehicleNo: ['车牌号/航班号/船号/班列号'],
|
||||
driverName: ['司机/船长'],
|
||||
transportType: ['运输类型', '运输方式'],
|
||||
cargoName: ['货物名称'],
|
||||
cargoType: ['货物类型'],
|
||||
quantity: ['重量'],
|
||||
transportType: ['运输方式', '运输类型'],
|
||||
driverName: ['司机/船长姓名', '司机/船长'],
|
||||
driverPhone: ['司机/船长手机号'],
|
||||
departureAddress: ['发货地址'],
|
||||
departureContact: ['发货联系人'],
|
||||
departurePhone: ['发货联系人电话'],
|
||||
arrivalAddress: ['到货地址'],
|
||||
arrivalContact: ['到货联系人', '收货联系人'],
|
||||
arrivalContact: ['收货联系人', '到货联系人'],
|
||||
arrivalPhone: ['收货联系人电话'],
|
||||
startDate: ['开始时间'],
|
||||
endDate: ['结束时间'],
|
||||
cargoName: ['货物名称'],
|
||||
cargoType: ['货物类型'],
|
||||
packageType: ['包装'],
|
||||
quantity: ['数量', '重量'],
|
||||
quantityUnit: ['数量单位'],
|
||||
specification: ['规格'],
|
||||
model: ['型号'],
|
||||
mileage: ['里程(km)', '里程'],
|
||||
unitPrice: ['单价'],
|
||||
freight: ['运费'],
|
||||
otherFeeTotal: ['其他费用合计'],
|
||||
freightTotal: ['运费合计'],
|
||||
actualStartDate: ['实际发货时间', '开始时间'],
|
||||
actualEndDate: ['实际完成时间', '结束时间'],
|
||||
planStartDate: ['预计发货时间'],
|
||||
planEndDate: ['预计完成时间'],
|
||||
remark: ['备注'],
|
||||
waybillIdentifier: ['同一运单标识号'],
|
||||
};
|
||||
const normalizeImportDate = value => {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||
@@ -1025,8 +1103,10 @@ const fileChange = async (file, list) => {
|
||||
item[key] ??
|
||||
'';
|
||||
});
|
||||
row.startDate = normalizeImportDate(row.startDate);
|
||||
row.endDate = normalizeImportDate(row.endDate);
|
||||
row.actualStartDate = normalizeImportDate(row.actualStartDate);
|
||||
row.actualEndDate = normalizeImportDate(row.actualEndDate);
|
||||
row.planStartDate = normalizeImportDate(row.planStartDate);
|
||||
row.planEndDate = normalizeImportDate(row.planEndDate);
|
||||
const duplicateKey = JSON.stringify(Object.keys(keyMap).map(key => row[key]));
|
||||
count.set(duplicateKey, (count.get(duplicateKey) || 0) + 1);
|
||||
row._duplicateKey = duplicateKey;
|
||||
@@ -1143,6 +1223,9 @@ const normalizeImportRow = row => ({
|
||||
otherFeeTotal: normalizeOptionalImportNumber(
|
||||
firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
|
||||
),
|
||||
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
|
||||
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
|
||||
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
|
||||
transportType: firstNotEmpty(
|
||||
row.transportType,
|
||||
row['运输类型'],
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
'waybill-manage-page',
|
||||
{
|
||||
'waybill-manage-page--form-page': isStandaloneWaybillFormPage,
|
||||
'waybill-manage-page--detail-page': isStandaloneWaybillDetailPage,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<component
|
||||
v-if="!isStandaloneWaybillDetailPage"
|
||||
:is="crudContainer"
|
||||
:option="pageFormOption"
|
||||
:form-page-title="isStandaloneWaybillFormPage ? formPageTitle : undefined"
|
||||
@@ -381,7 +383,7 @@
|
||||
>
|
||||
<span>货物信息</span>
|
||||
<div v-if="!dialogReadonly" class="waybill-manage-page__section-actions">
|
||||
<el-link type="primary" @click="cargoImportBox = true">导入货物</el-link>
|
||||
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
|
||||
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1514,7 +1516,7 @@
|
||||
v-model="attachmentRows"
|
||||
:readonly="dialogReadonly"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
@change="handleAttachmentChange"
|
||||
@@ -1653,24 +1655,29 @@
|
||||
</el-dialog>
|
||||
|
||||
<empty-pagination
|
||||
v-show="!isStandaloneWaybillFormPage"
|
||||
v-show="!isStandaloneWaybillFormPage && !isStandaloneWaybillDetailPage"
|
||||
:page="page"
|
||||
@size-change="sizeChange"
|
||||
@current-change="currentChange"
|
||||
@load="onLoad(page, query)"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
<component
|
||||
:is="detailContainer"
|
||||
v-model="detailBox"
|
||||
:title="`${config.title}详情`"
|
||||
append-to-body
|
||||
:append-to-body="!isStandaloneWaybillDetailPage"
|
||||
top="10px"
|
||||
width="96%"
|
||||
show-close
|
||||
:class="['waybill-manage-page__detail-dialog', $attrs.option?.dialogCustomClass]"
|
||||
:class="[
|
||||
'waybill-manage-page__detail-dialog',
|
||||
{ 'waybill-manage-page__detail-page': isStandaloneWaybillDetailPage },
|
||||
$attrs.option?.dialogCustomClass,
|
||||
]"
|
||||
>
|
||||
<div v-loading="detailLoading" class="waybill-manage-page__detail-content">
|
||||
<template v-if="detailBox">
|
||||
<template v-if="detailBox || isStandaloneWaybillDetailPage">
|
||||
<section-card class="waybill-manage-page__waybill-detail-summary">
|
||||
<div class="waybill-manage-page__waybill-heading">
|
||||
<strong>运单详情</strong>
|
||||
@@ -1775,7 +1782,6 @@
|
||||
transportCargoRows.length ? transportCargoRows : parseJsonArray(detailRow.goodsJson)
|
||||
"
|
||||
border
|
||||
height="220"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="70" />
|
||||
<el-table-column prop="cargoName" label="货物名称" min-width="150" />
|
||||
@@ -1982,10 +1988,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailBox = false">关闭</el-button>
|
||||
<div v-if="isStandaloneWaybillDetailPage" class="waybill-manage-page__detail-footer">
|
||||
<el-button type="primary" @click="closeDetail">关闭</el-button>
|
||||
</div>
|
||||
<template v-if="!isStandaloneWaybillDetailPage" #footer>
|
||||
<el-button type="primary" @click="closeDetail">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</component>
|
||||
|
||||
<el-dialog
|
||||
v-model="attachmentDocumentPreviewVisible"
|
||||
@@ -2527,7 +2536,10 @@
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div ref="transportAmap" class="waybill-manage-page__map"></div>
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
|
||||
<div ref="transportAmap" class="waybill-manage-page__map"></div>
|
||||
</div>
|
||||
<div class="waybill-manage-page__map-info">
|
||||
<span>{{ transportMapStatus }}</span>
|
||||
<span v-if="transportMapSelected.regionName"
|
||||
@@ -2815,6 +2827,7 @@ const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
|
||||
let amapLoader;
|
||||
const standaloneWaybillFormRoute = '/business/waybill-manage/form';
|
||||
const standaloneWaybillFormPaths = ['/business/waybill-manage', standaloneWaybillFormRoute];
|
||||
const standaloneWaybillDetailRoute = '/business/waybill-manage/detail';
|
||||
const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills';
|
||||
|
||||
const attachmentViewerPlugins = [
|
||||
@@ -2870,6 +2883,16 @@ const PageAvueForm = {
|
||||
},
|
||||
};
|
||||
|
||||
const PageDetail = {
|
||||
inheritAttrs: false,
|
||||
render() {
|
||||
return h('div', { class: this.$attrs.class }, [
|
||||
...(this.$slots.default?.() || []),
|
||||
...(this.$slots.footer?.() || []),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const estimateMenuButtonCount = config => {
|
||||
const rowActions = (config.actions || []).filter(action => action !== 'batchComplete');
|
||||
return 3 + rowActions.length + (config.operations || []).length;
|
||||
@@ -2905,7 +2928,7 @@ const defaultTransportCargo = () => ({
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const taskCarrierTypes = ['承运商', '自运', '网货平台'];
|
||||
const taskCarrierTypes = ['承运商', '自运'];
|
||||
|
||||
// Avue 默认分组使用折叠容器。独立运单表单需要始终展示各业务分区,
|
||||
// 因此仅替换本页面的分组渲染器,保留 avue-form 的字段渲染和校验能力。
|
||||
@@ -2955,6 +2978,7 @@ const getPlainAvueForm = avueForm => {
|
||||
export default {
|
||||
components: {
|
||||
PageAvueForm,
|
||||
PageDetail,
|
||||
InfoFilled,
|
||||
Rank,
|
||||
ElImageViewer,
|
||||
@@ -2991,6 +3015,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
standaloneDetailPage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -3144,6 +3172,7 @@ export default {
|
||||
transportMapKeyword: '',
|
||||
transportMapStatus: '可搜索地址或点击地图选点',
|
||||
transportMapSelected: {},
|
||||
transportMapSearchResults: [],
|
||||
transportAmap: null,
|
||||
transportAmapMarker: null,
|
||||
transportAmapGeocoder: null,
|
||||
@@ -3250,6 +3279,12 @@ export default {
|
||||
isStandaloneWaybillFormPage() {
|
||||
return this.isStandaloneWaybillPage && ['add', 'edit'].includes(this.$route.query.mode);
|
||||
},
|
||||
isStandaloneWaybillDetailPage() {
|
||||
return this.standaloneDetailPage && this.$route.path === standaloneWaybillDetailRoute;
|
||||
},
|
||||
detailContainer() {
|
||||
return this.isStandaloneWaybillDetailPage ? 'PageDetail' : 'el-dialog';
|
||||
},
|
||||
formPageTitle() {
|
||||
if (this.isStandaloneWaybillFormPage) {
|
||||
return (
|
||||
@@ -3338,7 +3373,7 @@ export default {
|
||||
return this.taskCarrierTypes;
|
||||
},
|
||||
taskCarrierLabel() {
|
||||
return this.form.carrierType === '网货平台' ? '承运方' : '承运商';
|
||||
return '承运商';
|
||||
},
|
||||
taskCarrierValue: {
|
||||
get() {
|
||||
@@ -3533,10 +3568,16 @@ export default {
|
||||
if (!this.isStandaloneWaybillFormPage) return;
|
||||
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
|
||||
const id = this.$route.query.id || '';
|
||||
const key = `${mode}:${id}`;
|
||||
const copyId = this.$route.query.copyId || '';
|
||||
const key = `${mode}:${id}:${copyId}`;
|
||||
if (this.standaloneFormKey === key) return;
|
||||
this.standaloneFormKey = key;
|
||||
if (mode === 'edit') this.form = { id };
|
||||
if (mode === 'edit') {
|
||||
this.form = { id };
|
||||
} else if (mode === 'add' && copyId) {
|
||||
// 复制模式:加载原数据
|
||||
this.form = { id: copyId };
|
||||
}
|
||||
this.beforeOpen(() => {}, mode);
|
||||
},
|
||||
buildFormGroupOption(option) {
|
||||
@@ -4034,7 +4075,8 @@ export default {
|
||||
waybillUnitPrice(row = {}) {
|
||||
const price = row.unitPrice || row.price || '';
|
||||
const unit = row.priceUnit || row.billingUnit || '';
|
||||
return price === '' ? '' : unit ? `${price}(${unit})` : price;
|
||||
if (price === '' || Number(price) === -1) return '';
|
||||
return unit ? `${price}(${unit})` : price;
|
||||
},
|
||||
waybillCurrencyRemark(row = {}) {
|
||||
const freight = this.parseJsonObject(row.freightJson);
|
||||
@@ -4066,6 +4108,10 @@ export default {
|
||||
return { value: `${this.waybillCurrencyRemark(row)}${value}` };
|
||||
},
|
||||
openDetail(row) {
|
||||
if (!this.isStandaloneWaybillDetailPage) {
|
||||
this.$router.push({ path: standaloneWaybillDetailRoute, query: { id: row.id } });
|
||||
return;
|
||||
}
|
||||
this.detailBox = true;
|
||||
this.detailLoading = true;
|
||||
this.detailRow = { ...row };
|
||||
@@ -4088,6 +4134,8 @@ export default {
|
||||
this.detailRow = {
|
||||
...detail,
|
||||
contractNo: contract.contractNo || detail.contractNo || detail.contractName || '',
|
||||
customerName:
|
||||
contract.partyA || contract.customerName || detail.customerName || '',
|
||||
};
|
||||
this.applyFormDetail(this.detailRow);
|
||||
return this.loadWaybillDetailProcessNodes(this.detailRow.projectId);
|
||||
@@ -4108,6 +4156,14 @@ export default {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
},
|
||||
closeDetail() {
|
||||
if (this.isStandaloneWaybillDetailPage) {
|
||||
this.$router.$avueRouter?.closeTag?.();
|
||||
this.$router.push({ path: '/business/waybill-manage', query: {} });
|
||||
return;
|
||||
}
|
||||
this.detailBox = false;
|
||||
},
|
||||
async openLoadingDetail(row = {}) {
|
||||
let id = row.loadingId || row.loadingManageId || '';
|
||||
if (!id && row.loadingNo) {
|
||||
@@ -4744,6 +4800,30 @@ export default {
|
||||
beforeOpen(done, type) {
|
||||
this.crudDialogType = type;
|
||||
this.dialogReadonly = type === 'view';
|
||||
const copyId = this.$route.query.copyId || '';
|
||||
|
||||
// 复制模式:先加载原数据,然后清除 ID 相关字段
|
||||
if (type === 'add' && copyId) {
|
||||
this.api.getDetail(copyId).then(res => {
|
||||
const sourceData = res.data.data || {};
|
||||
// 清除不应该复制的字段
|
||||
delete sourceData.id;
|
||||
delete sourceData.code;
|
||||
delete sourceData.waybillStatus;
|
||||
delete sourceData.createTime;
|
||||
delete sourceData.updateTime;
|
||||
delete sourceData.createUser;
|
||||
delete sourceData.updateUser;
|
||||
delete sourceData.createDept;
|
||||
|
||||
this.applyFormDetail(sourceData);
|
||||
// 标记为手工创建
|
||||
this.form.dataSource = '手工创建';
|
||||
done();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'add') {
|
||||
this.form = {
|
||||
...(this.config.defaultForm || {}),
|
||||
@@ -4998,7 +5078,11 @@ export default {
|
||||
this.form.settlementCurrency =
|
||||
contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || '';
|
||||
this.form.customerName =
|
||||
contract.customerName || contract.customerNames || this.form.customerName || '';
|
||||
contract.partyA ||
|
||||
contract.customerName ||
|
||||
contract.customerNames ||
|
||||
this.form.customerName ||
|
||||
'';
|
||||
this.syncTaskFreightCurrencyFromContract(contract);
|
||||
this.fillSelfOperatedCarrierFromContract(contract);
|
||||
this.$refs.crud?.validateField?.('contractName');
|
||||
@@ -5409,7 +5493,7 @@ export default {
|
||||
this.form.carrierId = '';
|
||||
},
|
||||
isTaskCarrierRequired(carrierType) {
|
||||
return ['承运商', '网货平台'].includes(String(carrierType || '').trim());
|
||||
return String(carrierType || '').trim() === '承运商';
|
||||
},
|
||||
taskCarrierOptionValue(item = {}) {
|
||||
if (this.form.carrierType !== '自运') {
|
||||
@@ -5986,7 +6070,7 @@ export default {
|
||||
['vehicleNo', this.transportVehicleNoLabel],
|
||||
];
|
||||
if (this.isTaskCarrierRequired(row.carrierType)) {
|
||||
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']);
|
||||
requiredFields.push(['carrierName', '承运商']);
|
||||
}
|
||||
if (row.carrierType === '承运商') {
|
||||
requiredFields.push(['carrierContractId', '承运商合同']);
|
||||
@@ -6805,6 +6889,12 @@ export default {
|
||||
this.ensureTransportAmapGeocoder()
|
||||
.then(() => this.runAmapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.transportMapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveMapPoint(result);
|
||||
if (!point) {
|
||||
this.transportMapStatus = '未找到匹配地址';
|
||||
@@ -6825,6 +6915,11 @@ export default {
|
||||
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
|
||||
});
|
||||
},
|
||||
selectTransportMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickTransportMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickTransportMapPoint(lnglat, keyword) {
|
||||
const longitude = this.getPointLng(lnglat);
|
||||
const latitude = this.getPointLat(lnglat);
|
||||
@@ -7559,6 +7654,18 @@ export default {
|
||||
openImportDialog(this, this.config.title, () => this.onLoad(this.page, this.query));
|
||||
},
|
||||
handleCopy(row) {
|
||||
if (this.isStandaloneWaybillPage) {
|
||||
this.$router.push({
|
||||
path: standaloneWaybillFormRoute,
|
||||
query: {
|
||||
mode: 'add',
|
||||
copyId: row.id,
|
||||
name: `复制${this.config.title || ''}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 非独立页面模式,使用原有的弹窗复制逻辑
|
||||
this.$confirm(`确定复制该${this.config.title}?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
@@ -8629,6 +8736,17 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
&__detail-page {
|
||||
padding: 12px;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
&__detail-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
&__waybill-detail-summary {
|
||||
:deep(.el-card__body) {
|
||||
padding: 18px 22px;
|
||||
@@ -8835,10 +8953,6 @@ export default {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
|
||||
.section-card {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__waybill-map-empty {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<el-table :data="contractFileRows" border class="change-table" @selection-change="selectedContractFiles = $event"><el-table-column type="selection" width="55" /><el-table-column type="index" label="序号" width="70" /><el-table-column label="文件名" min-width="240"><template #default="{ row }"><el-link type="primary" @click="previewAttachment(row, contractFileRows)">{{ row.originalName || row.name }}</el-link></template></el-table-column><el-table-column label="文件大小" width="120"><template #default="{ row }">{{ formatFileSize(row.size) }}</template></el-table-column><el-table-column prop="uploadUserName" label="上传人" width="140" /><el-table-column prop="uploadTime" label="上传时间" width="170" /><el-table-column label="操作" width="100"><template #default="{ $index }"><el-link type="danger" @click="removeContractFile($index)">删除</el-link></template></el-table-column></el-table>
|
||||
<div class="attachment-upload">
|
||||
<vehicle-attachment-upload v-model="contractFileRows" :readonly="false" :file-types="attachmentFileTypes" :max-size="500" :show-file-list="false" button-text="上传附件" @change="handleContractFileChange" />
|
||||
<vehicle-attachment-upload v-model="contractFileRows" :readonly="false" :file-types="attachmentFileTypes" :max-size="50" :show-file-list="false" button-text="上传附件" @change="handleContractFileChange" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<el-table-column label="文件大小" width="120"><template #default="{ row }">{{ formatFileSize(row.size) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="100"><template #default="{ $index }"><el-link type="danger" @click="changeMaterials.splice($index, 1)">删除</el-link></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="attachment-upload"><vehicle-attachment-upload v-model="changeMaterials" :readonly="false" :file-types="attachmentFileTypes" :max-size="500" :show-file-list="false" button-text="上传变更材料" /></div>
|
||||
<div class="attachment-upload"><vehicle-attachment-upload v-model="changeMaterials" :readonly="false" :file-types="attachmentFileTypes" :max-size="50" :show-file-list="false" button-text="上传变更材料" /></div>
|
||||
</section>
|
||||
<div class="page-footer">
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
@@ -148,7 +148,7 @@ export default {
|
||||
savePlan(value, index) { if (index < 0) this.plans.push(value); else this.plans.splice(index, 1, value); if (value.defaultPlan) this.plans.forEach((item, current) => { if (current !== (index < 0 ? this.plans.length - 1 : index)) item.defaultPlan = false; }); },
|
||||
handleSettlementTypeChange(value) { if (value !== '月结') { this.settlementRule.billCycleType = ''; this.settlementRule.billCutoffDay = ''; } else if (!this.settlementRule.billCycleType) this.settlementRule.billCycleType = '固定截单日'; if (value !== '固定天数周期结算') this.settlementRule.cycleDays = ''; },
|
||||
handleCycleTypeChange(value) { if (value === '固定截单日' && !this.settlementRule.billCutoffDay) this.settlementRule.billCutoffDay = 25; if (value !== '固定截单日') this.settlementRule.billCutoffDay = ''; },
|
||||
handleAttachment(event) { const raw = event.raw; if (raw) this.attachments.push({ name: raw.name, size: `${Math.ceil(raw.size / 1024)}KB`, uploadTime: this.$dayjs().format('YYYY-MM-DD HH:mm:ss') }); },
|
||||
handleAttachment(event) { const raw = event.raw; if (!raw) return; if (raw.size > 50 * 1024 * 1024) { this.$message.warning('单个文件大小不能超过50M'); return; } this.attachments.push({ name: raw.name, size: `${Math.ceil(raw.size / 1024)}KB`, uploadTime: this.$dayjs().format('YYYY-MM-DD HH:mm:ss') }); },
|
||||
handleContractFileChange(list) { this.contractFileRows = (list || []).map(item => ({ ...item, uploadTime: item.uploadTime || this.$dayjs().format('YYYY-MM-DD HH:mm:ss') })); },
|
||||
attachmentUrl(row = {}) { return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || ''; },
|
||||
attachmentName(row = {}) { return row.originalName || row.name || row.fileName || '附件'; },
|
||||
|
||||
@@ -1158,9 +1158,9 @@ const AttachmentSection = defineComponent({
|
||||
h(VehicleAttachmentUpload, {
|
||||
modelValue: this.rows,
|
||||
fileTypes: this.attachmentFileTypes,
|
||||
maxSize: 500,
|
||||
maxSize: 50,
|
||||
showTip: true,
|
||||
tip: '支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M',
|
||||
tip: '支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过50M',
|
||||
showFileList: false,
|
||||
buttonText: '上传附件',
|
||||
'onUpdate:modelValue': this.handleChange,
|
||||
|
||||
+1256
-696
File diff suppressed because it is too large
Load Diff
@@ -587,10 +587,10 @@
|
||||
class="project-apply-form__upload"
|
||||
:readonly="dialogReadonly"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
tip="支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M"
|
||||
tip="支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过50M"
|
||||
@change="handleAttachmentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,10 +117,10 @@
|
||||
v-model="attachmentRows"
|
||||
:readonly="dialogReadonly"
|
||||
:file-types="attachmentFileTypes"
|
||||
:max-size="500"
|
||||
:max-size="50"
|
||||
:show-file-list="false"
|
||||
button-text="上传附件"
|
||||
tip="支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M"
|
||||
tip="支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过50M"
|
||||
@change="handleAttachmentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<basic-container class="transport-plan-dispatch-page">
|
||||
<div v-loading="loading" class="transport-plan-dispatch-page__shell">
|
||||
<!-- 顶部信息区 -->
|
||||
<div class="transport-plan-dispatch-page__top">
|
||||
<div class="transport-plan-dispatch-page__heading">
|
||||
<div class="transport-plan-dispatch-page__heading-title">
|
||||
<el-button icon="el-icon-arrow-left" text @click="handleBack">返回</el-button>
|
||||
<span class="transport-plan-dispatch-page__heading-name">计划调度</span>
|
||||
<span class="transport-plan-dispatch-page__heading-no">
|
||||
{{ planData.planNo || planData.loadingNo || '-' }}
|
||||
</span>
|
||||
<el-tag :type="getStatusTagType(planData.businessStatus)" effect="light" class="status-text">
|
||||
{{ planData.businessStatusName || '-' }}
|
||||
</el-tag>
|
||||
<el-tag type="primary" effect="light">
|
||||
{{ planData.transportTypeName || '公路整车' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="transport-plan-dispatch-page__summary">
|
||||
<section-card title="基本信息" class="transport-plan-dispatch-page__summary-card">
|
||||
<div class="transport-plan-dispatch-page__summary-grid">
|
||||
<div class="transport-plan-dispatch-page__summary-item">
|
||||
<div class="transport-plan-dispatch-page__summary-label">客户</div>
|
||||
<div class="transport-plan-dispatch-page__summary-value">
|
||||
{{ planData.customerName || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__summary-item">
|
||||
<div class="transport-plan-dispatch-page__summary-label">项目</div>
|
||||
<div class="transport-plan-dispatch-page__summary-value">
|
||||
{{ planData.projectName || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__summary-item">
|
||||
<div class="transport-plan-dispatch-page__summary-label">合同编号</div>
|
||||
<div class="transport-plan-dispatch-page__summary-value">
|
||||
{{ planData.contractNo || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__summary-item">
|
||||
<div class="transport-plan-dispatch-page__summary-label">计划执行时间</div>
|
||||
<div class="transport-plan-dispatch-page__summary-value">
|
||||
{{ formatDateRange(planData.planStartDate, planData.planEndDate) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__summary-item is-wide">
|
||||
<div class="transport-plan-dispatch-page__summary-label">货物信息</div>
|
||||
<div class="transport-plan-dispatch-page__summary-value">
|
||||
{{ planData.goodsInfo || formatGoodsInfo(planData) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__summary-item">
|
||||
<div class="transport-plan-dispatch-page__summary-label">附件</div>
|
||||
<div class="transport-plan-dispatch-page__summary-value">
|
||||
<template v-if="attachmentList.length">
|
||||
<el-link
|
||||
v-for="(item, index) in attachmentList"
|
||||
:key="index"
|
||||
type="primary"
|
||||
:href="item.url"
|
||||
:underline="false"
|
||||
target="_blank"
|
||||
class="transport-plan-dispatch-page__attachment-link"
|
||||
>
|
||||
{{ item.name || item.originalName || '附件' }}
|
||||
</el-link>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section-card>
|
||||
|
||||
<section-card title="收发货路线" class="transport-plan-dispatch-page__route-card">
|
||||
<div class="transport-plan-dispatch-page__route">
|
||||
<div class="transport-plan-dispatch-page__route-item">
|
||||
<span class="transport-plan-dispatch-page__route-badge is-start">起</span>
|
||||
<div class="transport-plan-dispatch-page__route-body">
|
||||
<div class="transport-plan-dispatch-page__route-title">
|
||||
{{ planData.departureName || '-' }}
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__route-address">
|
||||
{{ formatAddress(planData.departureAddress) }}
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__route-contact">
|
||||
{{ formatContact(planData.departureContact, planData.departurePhone) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__route-vehicle">
|
||||
<el-icon><Van /></el-icon>
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__route-item">
|
||||
<span class="transport-plan-dispatch-page__route-badge is-end">终</span>
|
||||
<div class="transport-plan-dispatch-page__route-body">
|
||||
<div class="transport-plan-dispatch-page__route-title">
|
||||
{{ planData.arrivalName || '-' }}
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__route-address">
|
||||
{{ formatAddress(planData.arrivalAddress) }}
|
||||
</div>
|
||||
<div class="transport-plan-dispatch-page__route-contact">
|
||||
{{ formatContact(planData.arrivalContact, planData.arrivalPhone) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度列表 -->
|
||||
<section-card class="transport-plan-dispatch-page__list-card">
|
||||
<template #title>
|
||||
<span>待调度列表</span>
|
||||
<span class="transport-plan-dispatch-page__list-summary">
|
||||
{{ summaryText }}
|
||||
</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<el-button type="primary" :disabled="!canAddDispatch" @click="handleAdd">
|
||||
新增
|
||||
</el-button>
|
||||
</template>
|
||||
<el-table
|
||||
:data="dispatchList"
|
||||
border
|
||||
height="100%"
|
||||
row-key="id"
|
||||
class="transport-plan-dispatch-page__table"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="70" align="center" fixed="left" />
|
||||
<el-table-column label="运输方式" min-width="180" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.transportTypeName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="carrierType" label="承运类型" min-width="150" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="carrierName" label="承运商" min-width="150" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="driverName" label="司机" min-width="260" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDriver(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vehicleNo" label="车牌号" min-width="150" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="cargoType" label="货物类型" min-width="170" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="cargoInfo" label="货物信息" min-width="280" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.cargoInfo || formatCargoInfo(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货地址" min-width="220" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ formatAddress(row.departureAddress) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收货地址" min-width="220" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ formatAddress(row.arrivalAddress) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.quantity ? `${row.quantity}${row.quantityUnit || '吨'}` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unitPrice" label="单价" min-width="120" align="center" />
|
||||
<el-table-column prop="freight" label="运费" min-width="120" align="center" />
|
||||
<el-table-column prop="otherFeeTotal" label="其他费用" min-width="120" align="center" />
|
||||
<el-table-column prop="freightTotal" label="运费合计" min-width="120" align="center" />
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="transport-plan-dispatch-page__actions">
|
||||
<el-link type="primary" @click="handleEdit(row, $index)">编辑</el-link>
|
||||
<el-link type="danger" @click="handleDelete($index)">删除</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section-card>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="transport-plan-dispatch-page__footer">
|
||||
<el-button @click="handleBack">关闭</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSubmit">
|
||||
确认生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度明细编辑弹窗 - 将使用原组件的弹窗 -->
|
||||
<transport-plan-page
|
||||
v-if="transportPlanPageVisible"
|
||||
ref="transportPlanPageRef"
|
||||
:api="api"
|
||||
:config="config"
|
||||
:crud-option="option"
|
||||
style="display: none"
|
||||
/>
|
||||
</basic-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Van } from '@element-plus/icons-vue';
|
||||
import { getDetail, dispatch } from '@/api/business/transport-plan';
|
||||
import SectionCard from '@/components/section-card/main.vue';
|
||||
import TransportPlanPage from './components/transport-plan-page.vue';
|
||||
import * as api from '@/api/business/transport-plan';
|
||||
import { config, option } from '@/option/business/transport-plan';
|
||||
|
||||
const TRANSPORT_PLAN_QUANTITY_UNIT = '吨';
|
||||
|
||||
export default {
|
||||
name: 'TransportPlanDispatch',
|
||||
components: {
|
||||
Van,
|
||||
SectionCard,
|
||||
TransportPlanPage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
planData: {},
|
||||
dispatchList: [],
|
||||
attachmentList: [],
|
||||
transportPlanPageVisible: false,
|
||||
api,
|
||||
config,
|
||||
option,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
planId() {
|
||||
return this.$route.query.planId;
|
||||
},
|
||||
summaryText() {
|
||||
const goodsList = this.parseGoodsList(this.planData.goodsJson);
|
||||
if (!goodsList.length) {
|
||||
const total = Number(this.planData.totalQuantity || 0);
|
||||
const dispatched = this.calculateDispatchedQuantity();
|
||||
const remaining = Math.max(total - dispatched, 0);
|
||||
return `共${total}吨 | 已调度 ${dispatched.toFixed(2)}吨,剩余${remaining.toFixed(2)}吨`;
|
||||
}
|
||||
|
||||
const summary = {};
|
||||
goodsList.forEach(goods => {
|
||||
const unit = goods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
|
||||
if (!summary[unit]) {
|
||||
summary[unit] = { total: 0, dispatched: 0 };
|
||||
}
|
||||
summary[unit].total += Number(goods.quantity || 0);
|
||||
});
|
||||
|
||||
this.dispatchList.forEach(item => {
|
||||
const unit = item.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
|
||||
if (!summary[unit]) {
|
||||
summary[unit] = { total: 0, dispatched: 0 };
|
||||
}
|
||||
summary[unit].dispatched += Number(item.quantity || 0);
|
||||
});
|
||||
|
||||
const parts = Object.entries(summary).map(([unit, data]) => {
|
||||
const remaining = Math.max(data.total - data.dispatched, 0);
|
||||
return `${data.total}${unit} | 已调度 ${data.dispatched.toFixed(2)}${unit},剩余${remaining.toFixed(
|
||||
2
|
||||
)}${unit}`;
|
||||
});
|
||||
|
||||
return parts.join(' | ');
|
||||
},
|
||||
canAddDispatch() {
|
||||
const goodsList = this.parseGoodsList(this.planData.goodsJson);
|
||||
if (!goodsList.length) {
|
||||
const total = Number(this.planData.totalQuantity || 0);
|
||||
const dispatched = this.calculateDispatchedQuantity();
|
||||
return dispatched < total;
|
||||
}
|
||||
|
||||
return goodsList.some(goods => {
|
||||
const unit = goods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
|
||||
const totalQty = Number(goods.quantity || 0);
|
||||
const dispatchedQty = this.dispatchList
|
||||
.filter(item => item.quantityUnit === unit)
|
||||
.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
|
||||
return dispatchedQty < totalQty;
|
||||
});
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.loadPlanData();
|
||||
},
|
||||
mounted() {
|
||||
// 初始化 transport-plan-page 组件用于打开弹窗
|
||||
this.transportPlanPageVisible = true;
|
||||
},
|
||||
methods: {
|
||||
loadPlanData() {
|
||||
if (!this.planId) {
|
||||
this.$message.error('缺少计划ID参数');
|
||||
this.handleBack();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
getDetail(this.planId)
|
||||
.then(res => {
|
||||
if (res.data?.success) {
|
||||
this.planData = res.data.data || {};
|
||||
this.attachmentList = this.parseAttachments(this.planData.attachmentsJson);
|
||||
this.dispatchList = this.parseDispatchList(this.planData);
|
||||
} else {
|
||||
this.$message.error(res.data?.msg || '加载数据失败');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('加载计划数据失败:', err);
|
||||
this.$message.error('加载数据失败');
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
parseAttachments(json) {
|
||||
try {
|
||||
const data = JSON.parse(json || '[]');
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
parseDispatchList(planData) {
|
||||
try {
|
||||
const sources = [
|
||||
planData.waybillList,
|
||||
planData.dispatchRows,
|
||||
planData.dispatchRecordList,
|
||||
planData.dispatchList,
|
||||
];
|
||||
for (const source of sources) {
|
||||
if (Array.isArray(source) && source.length) {
|
||||
return source.map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || `temp-${index}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
parseGoodsList(json) {
|
||||
try {
|
||||
const data = JSON.parse(json || '[]');
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
calculateDispatchedQuantity() {
|
||||
return this.dispatchList.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
|
||||
},
|
||||
getStatusTagType(status) {
|
||||
const statusMap = {
|
||||
0: 'info',
|
||||
1: 'warning',
|
||||
2: 'success',
|
||||
3: 'danger',
|
||||
};
|
||||
return statusMap[status] || 'info';
|
||||
},
|
||||
formatDateRange(startDate, endDate) {
|
||||
const dates = [startDate, endDate].filter(Boolean);
|
||||
return dates.length ? dates.join(' ~ ') : '-';
|
||||
},
|
||||
formatAddress(address) {
|
||||
if (!address) return '-';
|
||||
const match = address.match(/^(.*?省)?(.*?市)?(.*?区|.*?县)?/);
|
||||
return match ? match[0] || address : address;
|
||||
},
|
||||
formatContact(contact, phone) {
|
||||
const parts = [contact, phone].filter(Boolean);
|
||||
return parts.length ? parts.join(' / ') : '-';
|
||||
},
|
||||
formatDriver(row) {
|
||||
const parts = [row.driverName, row.driverPhone].filter(Boolean);
|
||||
return parts.length ? parts.join(' / ') : '-';
|
||||
},
|
||||
formatGoodsInfo(data) {
|
||||
const goodsList = this.parseGoodsList(data.goodsJson);
|
||||
if (!goodsList.length) {
|
||||
return data.cargoName || '-';
|
||||
}
|
||||
return goodsList
|
||||
.map(goods => {
|
||||
const parts = [goods.cargoName || goods.goodsName];
|
||||
if (goods.quantity) {
|
||||
parts.push(`${goods.quantity}${goods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT}`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
})
|
||||
.join(';');
|
||||
},
|
||||
formatCargoInfo(row) {
|
||||
const parts = [row.cargoName];
|
||||
if (row.specification) parts.push(row.specification);
|
||||
if (row.model) parts.push(row.model);
|
||||
return parts.filter(Boolean).join(' / ') || '-';
|
||||
},
|
||||
handleBack() {
|
||||
this.$router.back();
|
||||
},
|
||||
handleAdd() {
|
||||
if (!this.canAddDispatch) {
|
||||
this.$message.warning('待调度列表货物总量已达到计划总量,不能新增调度明细');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用 transport-plan-page 组件的方法打开弹窗
|
||||
this.$nextTick(() => {
|
||||
const component = this.$refs.transportPlanPageRef;
|
||||
if (component) {
|
||||
// 设置调度数据
|
||||
component.dispatchRow = this.planData;
|
||||
component.dispatchRows = [...this.dispatchList];
|
||||
// 打开新增弹窗
|
||||
component.openDispatchItemDialog(-1);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleEdit(row, index) {
|
||||
// 调用 transport-plan-page 组件的方法打开弹窗
|
||||
this.$nextTick(() => {
|
||||
const component = this.$refs.transportPlanPageRef;
|
||||
if (component) {
|
||||
// 设置调度数据
|
||||
component.dispatchRow = this.planData;
|
||||
component.dispatchRows = [...this.dispatchList];
|
||||
// 打开编辑弹窗
|
||||
component.openDispatchItemDialog(index, row);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleDelete(index) {
|
||||
this.$confirm('确定删除该调度明细?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
this.dispatchList.splice(index, 1);
|
||||
this.$message.success('删除成功');
|
||||
});
|
||||
},
|
||||
handleSubmit() {
|
||||
if (!this.dispatchList.length) {
|
||||
this.$message.warning('请先添加调度明细');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [index, row] of this.dispatchList.entries()) {
|
||||
if (!row.carrierName) {
|
||||
this.$message.warning(`第${index + 1}条调度明细缺少承运商信息`);
|
||||
return;
|
||||
}
|
||||
if (!row.driverName) {
|
||||
this.$message.warning(`第${index + 1}条调度明细缺少司机信息`);
|
||||
return;
|
||||
}
|
||||
if (!row.vehicleNo) {
|
||||
this.$message.warning(`第${index + 1}条调度明细缺少车牌号`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.$confirm(
|
||||
`请确认调度信息,将生成 <span style="color: #409eff; font-size: 20px; padding: 0 4px;">${this.dispatchList.length}</span> 条运单,是否继续?`,
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
dangerouslyUseHTMLString: true,
|
||||
}
|
||||
).then(() => {
|
||||
this.submitDispatch();
|
||||
});
|
||||
},
|
||||
submitDispatch() {
|
||||
this.saving = true;
|
||||
const payload = {
|
||||
id: this.planId,
|
||||
mode: 'submit',
|
||||
waybills: this.dispatchList.map(row => ({
|
||||
...row,
|
||||
taskEntryMode: row.taskEntryMode || 'simple',
|
||||
taskRemark: row.remark || '',
|
||||
estimatedStartTime: row.estimatedStartTime || this.planData.planStartDate || '',
|
||||
estimatedEndTime: row.estimatedEndTime || this.planData.planEndDate || '',
|
||||
goodsJson:
|
||||
row.goodsJson ||
|
||||
JSON.stringify([
|
||||
{
|
||||
cargoName: row.cargoName,
|
||||
cargoType: row.cargoType,
|
||||
quantity: row.quantity,
|
||||
quantityUnit: row.quantityUnit,
|
||||
specification: row.specification,
|
||||
model: row.model,
|
||||
packageType: row.packageType,
|
||||
brand: row.brand,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
};
|
||||
|
||||
dispatch(payload)
|
||||
.then(res => {
|
||||
if (res.data?.success) {
|
||||
this.$message.success('调度成功,运单已生成');
|
||||
this.handleBack();
|
||||
} else {
|
||||
this.$message.error(res.data?.msg || '调度失败');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('调度失败:', err);
|
||||
this.$message.error('调度失败');
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.transport-plan-dispatch-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&-no {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
&-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.is-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
&-value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
&__route-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
&-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.is-start {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.is-end {
|
||||
background: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-address,
|
||||
&-contact {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
&-vehicle {
|
||||
font-size: 24px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
&__list-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__list-summary {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&__table {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__attachment-link {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<basic-container class="transport-plan-import-page">
|
||||
<div class="transport-plan-import-page__form-wrapper">
|
||||
<div class="transport-plan-import-page__title">导入运输计划</div>
|
||||
|
||||
<el-form
|
||||
ref="importFormRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="right"
|
||||
label-width="auto"
|
||||
class="transport-plan-import-page__form"
|
||||
>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="项目" prop="projectId" required>
|
||||
<el-select
|
||||
v-model="form.projectId"
|
||||
placeholder="模糊查询后选择"
|
||||
filterable
|
||||
clearable
|
||||
:loading="projectLoading"
|
||||
@visible-change="visible => visible && loadProjectOptions()"
|
||||
@change="handleProjectChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projectOptions"
|
||||
:key="item.id"
|
||||
:label="item.projectName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="客户" prop="customerName" required>
|
||||
<el-input v-model="form.customerName" disabled placeholder="选择项目后带出" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="客户合同" prop="contractId" required>
|
||||
<el-select
|
||||
v-model="form.contractId"
|
||||
placeholder="选择项目后选择"
|
||||
filterable
|
||||
clearable
|
||||
:loading="contractLoading"
|
||||
:disabled="!form.projectId"
|
||||
@change="handleContractChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in contractOptions"
|
||||
:key="item.id"
|
||||
:label="item.contractName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item
|
||||
label="计划明细表"
|
||||
prop="file"
|
||||
required
|
||||
class="transport-plan-import-page__file-item"
|
||||
>
|
||||
<el-upload
|
||||
action="#"
|
||||
accept=".xls,.xlsx"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-list="files"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
>
|
||||
<el-button type="primary">添加附件</el-button>
|
||||
</el-upload>
|
||||
<span class="transport-plan-import-page__file-tip">
|
||||
请上传计划明细表,仅支持 Excel 格式
|
||||
</span>
|
||||
<el-link type="primary" @click="handleTemplateDownload">下载模板</el-link>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<section v-if="rows.length" class="transport-plan-import-page__preview">
|
||||
<div class="transport-plan-import-page__preview-head">
|
||||
<div class="dialog-section-title">数据明细</div>
|
||||
<el-button type="danger" plain :disabled="!selection.length" @click="removeSelectedRows">
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" type="border-card" class="transport-plan-import-page__tabs">
|
||||
<el-tab-pane :label="`计划单数(${rows.length})`" name="all" />
|
||||
<el-tab-pane :label="`疑似重复(${duplicateCount})`" name="duplicate" />
|
||||
</el-tabs>
|
||||
<div class="transport-plan-import-page__table">
|
||||
<el-table
|
||||
:data="visibleRows"
|
||||
border
|
||||
row-key="_key"
|
||||
empty-text="暂无明细数据"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="52" fixed="left" />
|
||||
<el-table-column type="index" label="序号" width="70" fixed="left" />
|
||||
<el-table-column label="计划单号" min-width="150">
|
||||
<template #default>待生成</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="column in previewColumns"
|
||||
:key="column.prop"
|
||||
:label="column.label"
|
||||
:min-width="column.width"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="isEditingRow(row) && column.prop === 'quantityUnit'"
|
||||
v-model="row[column.prop]"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in quantityUnitOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else-if="isEditingRow(row)"
|
||||
v-model="row[column.prop]"
|
||||
:maxlength="column.prop === 'remark' ? 200 : undefined"
|
||||
:show-word-limit="column.prop === 'remark'"
|
||||
/>
|
||||
<span v-else>{{ row[column.prop] || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="transport-plan-import-page__actions">
|
||||
<template v-if="isEditingRow(row)">
|
||||
<el-link type="primary" @click="saveRow">保存</el-link>
|
||||
<el-link type="primary" @click="cancelEditRow(row)">取消</el-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-link type="primary" @click="editRow(row)">编辑</el-link>
|
||||
<el-link type="danger" @click="removeRow(row)">删除</el-link>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="transport-plan-import-page__footer">
|
||||
<el-button :disabled="loading" @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">提交</el-button>
|
||||
</div>
|
||||
</basic-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { exportBlob } from '@/api/common';
|
||||
import { getList as getContractList } from '@/api/business/contract-manage';
|
||||
import { getList as getProjectList } from '@/api/business/project-apply';
|
||||
import { importTransportPlan } from '@/api/business/transport-plan';
|
||||
import { config } from '@/option/business/transport-plan';
|
||||
import { downloadXls } from '@/utils/util';
|
||||
|
||||
const importColumns = [
|
||||
['计划名称', 'planName'],
|
||||
['运输类型', 'transportType'],
|
||||
['发货地址', 'departureAddress'],
|
||||
['发货联系人', 'departureContact'],
|
||||
['发货联系人电话', 'departurePhone'],
|
||||
['到货地址', 'arrivalAddress'],
|
||||
['收货联系人', 'arrivalContact'],
|
||||
['收货联系人电话', 'arrivalPhone'],
|
||||
['货物名称', 'cargoName'],
|
||||
['货物类型', 'cargoType'],
|
||||
['数量', 'quantity'],
|
||||
['计量单位', 'quantityUnit'],
|
||||
['包装', 'packageType'],
|
||||
['规格', 'specification'],
|
||||
['型号', 'model'],
|
||||
['里程(km)', 'mileage'],
|
||||
['计划开始时间', 'planStartDate'],
|
||||
['计划结束时间', 'planEndDate'],
|
||||
['备注', 'remark'],
|
||||
['同一计划标识号', 'planGroupId'],
|
||||
];
|
||||
|
||||
const previewColumns = [
|
||||
{ label: '计划名称', prop: 'planName', width: 180 },
|
||||
{ label: '运输类型', prop: 'transportType', width: 120 },
|
||||
{ label: '发货地址', prop: 'departureAddress', width: 260 },
|
||||
{ label: '发货联系人', prop: 'departureContact', width: 120 },
|
||||
{ label: '发货联系人电话', prop: 'departurePhone', width: 140 },
|
||||
{ label: '到货地址', prop: 'arrivalAddress', width: 260 },
|
||||
{ label: '收货联系人', prop: 'arrivalContact', width: 120 },
|
||||
{ label: '收货联系人电话', prop: 'arrivalPhone', width: 140 },
|
||||
{ label: '货物名称', prop: 'cargoName', width: 140 },
|
||||
{ label: '货物类型', prop: 'cargoType', width: 120 },
|
||||
{ label: '数量', prop: 'quantity', width: 100 },
|
||||
{ label: '计量单位', prop: 'quantityUnit', width: 110 },
|
||||
{ label: '包装', prop: 'packageType', width: 100 },
|
||||
{ label: '规格', prop: 'specification', width: 120 },
|
||||
{ label: '型号', prop: 'model', width: 120 },
|
||||
{ label: '里程(km)', prop: 'mileage', width: 120 },
|
||||
{ label: '计划开始时间', prop: 'planStartDate', width: 140 },
|
||||
{ label: '计划结束时间', prop: 'planEndDate', width: 140 },
|
||||
{ label: '备注', prop: 'remark', width: 180 },
|
||||
{ label: '同一计划标识号', prop: 'planGroupId', width: 150 },
|
||||
];
|
||||
|
||||
const extractRecords = res => {
|
||||
const data = res?.data?.data || res?.data || res;
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data?.records)) return data.records;
|
||||
if (Array.isArray(data?.data)) return data.data;
|
||||
if (Array.isArray(data?.data?.records)) return data.data.records;
|
||||
return [];
|
||||
};
|
||||
|
||||
const defaultForm = () => ({
|
||||
projectId: '',
|
||||
projectName: '',
|
||||
customerName: '',
|
||||
contractId: '',
|
||||
contractName: '',
|
||||
file: null,
|
||||
});
|
||||
|
||||
export default {
|
||||
name: 'TransportPlanImport',
|
||||
data() {
|
||||
return {
|
||||
form: defaultForm(),
|
||||
rules: {
|
||||
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
|
||||
customerName: [{ required: true, message: '请选择项目后带出客户', trigger: 'change' }],
|
||||
contractId: [{ required: true, message: '请选择项目后带出客户合同', trigger: 'change' }],
|
||||
file: [{ required: true, message: '请上传计划明细表', trigger: 'change' }],
|
||||
},
|
||||
projectOptions: [],
|
||||
projectLoading: false,
|
||||
contractOptions: [],
|
||||
contractLoading: false,
|
||||
files: [],
|
||||
sourceFile: null,
|
||||
rows: [],
|
||||
selection: [],
|
||||
activeTab: 'all',
|
||||
editingKey: '',
|
||||
editSnapshot: null,
|
||||
loading: false,
|
||||
previewColumns,
|
||||
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
duplicateCount() {
|
||||
return this.rows.filter(row => row._duplicate).length;
|
||||
},
|
||||
visibleRows() {
|
||||
return this.activeTab === 'duplicate' ? this.rows.filter(row => row._duplicate) : this.rows;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.loadProjectOptions();
|
||||
},
|
||||
methods: {
|
||||
loadProjectOptions() {
|
||||
if (this.projectLoading) return;
|
||||
this.projectLoading = true;
|
||||
getProjectList(1, 9999, config.projectQueryParams || {})
|
||||
.then(res => {
|
||||
this.projectOptions = extractRecords(res);
|
||||
})
|
||||
.finally(() => {
|
||||
this.projectLoading = false;
|
||||
});
|
||||
},
|
||||
handleProjectChange(projectId) {
|
||||
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
|
||||
this.form.customerName =
|
||||
project?.customerName || project?.customerNames || project?.customer || '';
|
||||
this.form.projectName = project?.projectName || '';
|
||||
this.form.contractId = '';
|
||||
this.form.contractName = '';
|
||||
this.contractOptions = [];
|
||||
if (!projectId) return;
|
||||
this.contractLoading = true;
|
||||
getContractList(1, 9999, {
|
||||
projectId,
|
||||
projectName: project?.projectName,
|
||||
...(config.contractQueryParams || {}),
|
||||
})
|
||||
.then(res => {
|
||||
this.contractOptions = extractRecords(res);
|
||||
const contract = this.contractOptions[0];
|
||||
this.form.customerName =
|
||||
this.form.customerName || contract?.customerName || contract?.partyA || '';
|
||||
this.form.contractId = contract?.id || '';
|
||||
this.form.contractName = contract?.contractName || '';
|
||||
})
|
||||
.finally(() => {
|
||||
this.contractLoading = false;
|
||||
});
|
||||
},
|
||||
handleContractChange(contractId) {
|
||||
const contract = this.contractOptions.find(item => String(item.id) === String(contractId));
|
||||
this.form.contractName = contract?.contractName || '';
|
||||
this.form.customerName =
|
||||
contract?.customerName || contract?.partyA || this.form.customerName || '';
|
||||
},
|
||||
async handleFileChange(file, fileList) {
|
||||
if (!/\.(xls|xlsx)$/i.test(file.name || '')) {
|
||||
this.$message.error('请上传 .xls 或 .xlsx 格式文件');
|
||||
this.files = [];
|
||||
this.form.file = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const XLSX = await import('xlsx');
|
||||
const workbook = XLSX.read(await file.raw.arrayBuffer(), {
|
||||
type: 'array',
|
||||
cellDates: false,
|
||||
});
|
||||
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const values = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false,
|
||||
blankrows: false,
|
||||
});
|
||||
const headers = values[0] || [];
|
||||
const headerMap = headers.reduce((result, label, index) => {
|
||||
const normalized = String(label || '')
|
||||
.replace(/^\*/, '')
|
||||
.trim();
|
||||
if (normalized) result[normalized] = index;
|
||||
return result;
|
||||
}, {});
|
||||
const rows = values.slice(1).reduce((result, cells, index) => {
|
||||
const row = { _key: `${Date.now()}-${index}-${Math.random().toString(36).slice(2)}` };
|
||||
importColumns.forEach(([label, prop]) => {
|
||||
row[prop] = String(cells[headerMap[label]] ?? '').trim();
|
||||
});
|
||||
if (importColumns.some(([, prop]) => row[prop])) result.push(row);
|
||||
return result;
|
||||
}, []);
|
||||
if (!rows.length) {
|
||||
this.$message.warning('未读取到计划明细数据');
|
||||
this.handleFileRemove();
|
||||
return;
|
||||
}
|
||||
this.files = fileList.slice(-1);
|
||||
this.sourceFile = file.raw;
|
||||
this.form.file = file.raw;
|
||||
this.rows = rows;
|
||||
this.markDuplicates();
|
||||
this.$refs.importFormRef?.validateField('file');
|
||||
this.$message.success(`已读取${rows.length}条计划明细`);
|
||||
} catch (error) {
|
||||
this.handleFileRemove();
|
||||
this.$message.error('计划明细表读取失败,请检查文件格式');
|
||||
}
|
||||
},
|
||||
handleFileRemove() {
|
||||
this.files = [];
|
||||
this.sourceFile = null;
|
||||
this.form.file = null;
|
||||
this.rows = [];
|
||||
this.selection = [];
|
||||
this.editingKey = '';
|
||||
this.editSnapshot = null;
|
||||
},
|
||||
duplicateKey(row) {
|
||||
return importColumns.map(([, prop]) => String(row[prop] || '').trim()).join('\u0001');
|
||||
},
|
||||
markDuplicates() {
|
||||
const countMap = this.rows.reduce((result, row) => {
|
||||
const key = this.duplicateKey(row);
|
||||
result[key] = (result[key] || 0) + 1;
|
||||
return result;
|
||||
}, {});
|
||||
this.rows.forEach(row => {
|
||||
row._duplicate = countMap[this.duplicateKey(row)] > 1;
|
||||
});
|
||||
},
|
||||
handleSelectionChange(rows) {
|
||||
this.selection = rows;
|
||||
},
|
||||
isEditingRow(row) {
|
||||
return this.editingKey === row._key;
|
||||
},
|
||||
editRow(row) {
|
||||
if (this.editingKey && this.editingKey !== row._key) {
|
||||
this.$message.warning('请先保存或取消当前编辑');
|
||||
return;
|
||||
}
|
||||
this.editingKey = row._key;
|
||||
this.editSnapshot = { ...row };
|
||||
},
|
||||
saveRow() {
|
||||
this.editingKey = '';
|
||||
this.editSnapshot = null;
|
||||
this.markDuplicates();
|
||||
},
|
||||
cancelEditRow(row) {
|
||||
Object.assign(row, this.editSnapshot || {});
|
||||
this.editingKey = '';
|
||||
this.editSnapshot = null;
|
||||
this.markDuplicates();
|
||||
},
|
||||
removeRow(row) {
|
||||
this.rows = this.rows.filter(item => item._key !== row._key);
|
||||
this.selection = this.selection.filter(item => item._key !== row._key);
|
||||
this.markDuplicates();
|
||||
},
|
||||
removeSelectedRows() {
|
||||
const selectedKeys = new Set(this.selection.map(row => row._key));
|
||||
this.rows = this.rows.filter(row => !selectedKeys.has(row._key));
|
||||
this.selection = [];
|
||||
this.markDuplicates();
|
||||
},
|
||||
validateRows() {
|
||||
if (!this.rows.length) {
|
||||
this.$message.warning('请保留至少一条计划明细');
|
||||
return false;
|
||||
}
|
||||
const requiredFields = [
|
||||
['planName', '计划名称'],
|
||||
['transportType', '运输类型'],
|
||||
['departureAddress', '发货地址'],
|
||||
['arrivalAddress', '到货地址'],
|
||||
['cargoType', '货物类型'],
|
||||
];
|
||||
const isValidDate = value => {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return false;
|
||||
const [, year, month, day] = match;
|
||||
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
||||
return (
|
||||
date.getFullYear() === Number(year) &&
|
||||
date.getMonth() === Number(month) - 1 &&
|
||||
date.getDate() === Number(day)
|
||||
);
|
||||
};
|
||||
for (const [index, row] of this.rows.entries()) {
|
||||
const emptyField = requiredFields.find(([prop]) => !String(row[prop] || '').trim());
|
||||
if (emptyField) {
|
||||
this.$message.warning(`第${index + 1}行${emptyField[1]}不能为空`);
|
||||
return false;
|
||||
}
|
||||
if (row.planStartDate && !isValidDate(row.planStartDate)) {
|
||||
this.$message.warning(`第${index + 1}行计划开始时间格式必须为 YYYY-MM-DD`);
|
||||
return false;
|
||||
}
|
||||
if (row.planEndDate && !isValidDate(row.planEndDate)) {
|
||||
this.$message.warning(`第${index + 1}行计划结束时间格式必须为 YYYY-MM-DD`);
|
||||
return false;
|
||||
}
|
||||
if (row.planStartDate && row.planEndDate && row.planEndDate < row.planStartDate) {
|
||||
this.$message.warning(`第${index + 1}行计划结束时间不能早于计划开始时间`);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
row.quantity !== '' &&
|
||||
(!Number.isFinite(Number(row.quantity)) || Number(row.quantity) < 0)
|
||||
) {
|
||||
this.$message.warning(`第${index + 1}行数量必须为非负数`);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
row.mileage !== '' &&
|
||||
(!Number.isFinite(Number(row.mileage)) || Number(row.mileage) < 0)
|
||||
) {
|
||||
this.$message.warning(`第${index + 1}行里程必须为非负数`);
|
||||
return false;
|
||||
}
|
||||
if (String(row.remark || '').length > 200) {
|
||||
this.$message.warning(`第${index + 1}行备注不能超过200个字`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async buildImportFile() {
|
||||
const XLSX = await import('xlsx');
|
||||
const requiredLabels = [
|
||||
'计划名称',
|
||||
'运输类型',
|
||||
'发货地址',
|
||||
'到货地址',
|
||||
'货物类型',
|
||||
];
|
||||
const values = [
|
||||
importColumns.map(([label]) => (requiredLabels.includes(label) ? `*${label}` : label)),
|
||||
...this.rows.map(row => importColumns.map(([, prop]) => String(row[prop] ?? ''))),
|
||||
];
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(values), '运输计划导入模板');
|
||||
const binary = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
const fileName = (this.sourceFile?.name || '运输计划导入模板.xlsx').replace(
|
||||
/\.xls$/i,
|
||||
'.xlsx'
|
||||
);
|
||||
return new File([binary], fileName, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
},
|
||||
handleTemplateDownload() {
|
||||
exportBlob(config.transportPlanTemplateUrl, {}, { feedback: true }).then(res => {
|
||||
downloadXls(res.data, '运输计划导入模板.xlsx');
|
||||
});
|
||||
},
|
||||
async handleSubmit() {
|
||||
const valid = await this.$refs.importFormRef?.validate().catch(() => false);
|
||||
if (!valid || this.loading || !this.validateRows()) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const file = await this.buildImportFile();
|
||||
const res = await importTransportPlan({ ...this.form, file });
|
||||
const contentType = res.headers?.['content-type'] || res.data?.type || '';
|
||||
if (
|
||||
contentType.includes('application/vnd.ms-excel') ||
|
||||
contentType.includes('spreadsheetml')
|
||||
) {
|
||||
downloadXls(
|
||||
res.data,
|
||||
`运输计划导入失败明细${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
|
||||
);
|
||||
this.$message.warning('部分数据导入失败,已下载失败明细');
|
||||
} else {
|
||||
const text = await res.data.text();
|
||||
const result = text ? JSON.parse(text) : {};
|
||||
if (result.code !== 200) throw new Error(result.msg || '导入失败');
|
||||
this.$message.success('导入完成');
|
||||
}
|
||||
this.$router.push('/business/transport-plan');
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || '导入失败');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
handleCancel() {
|
||||
this.$router.push('/business/transport-plan');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.transport-plan-import-page {
|
||||
min-height: calc(100vh - 112px);
|
||||
|
||||
&__form-wrapper {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid #eff1f7;
|
||||
color: #303133;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #409eff;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
padding: 24px 24px 4px;
|
||||
|
||||
.el-select,
|
||||
.el-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-item {
|
||||
margin-top: 12px;
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__file-tip {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
margin: 8px 24px 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eff1f7;
|
||||
}
|
||||
|
||||
&__preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.dialog-section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__table {
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.el-table) {
|
||||
min-width: 2400px;
|
||||
}
|
||||
|
||||
:deep(.el-table__cell) {
|
||||
border-color: #eff1f7;
|
||||
}
|
||||
|
||||
:deep(.el-table__row:nth-child(even) td) {
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 230px;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #eff1f7;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.avue--collapse .transport-plan-import-page__footer) {
|
||||
left: 60px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<basic-container class="voucher-folder-page">
|
||||
<section class="voucher-folder-page__search">
|
||||
<el-form :model="query" label-position="right" label-width="160px">
|
||||
<div class="voucher-folder-page__search-grid">
|
||||
<el-form-item label="车牌号"><el-input v-model="query.plateNo" clearable placeholder="请输入" /></el-form-item>
|
||||
<el-form-item label="是否关联运单">
|
||||
<el-select v-model="query.matched" clearable placeholder="全部"><el-option label="是" :value="1" /><el-option label="否" :value="0" /></el-select>
|
||||
</el-form-item>
|
||||
<div class="voucher-folder-page__search-actions"><el-button type="primary" @click="search">查询</el-button><el-button @click="reset">重置</el-button></div>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
<section class="voucher-folder-page__table-wrap">
|
||||
<div class="voucher-folder-page__title">{{ voucher.voucherBatchNo || '执行凭证详情' }}</div>
|
||||
<el-table v-loading="loading" :data="rows" border class="voucher-folder-page__table">
|
||||
<el-table-column type="index" label="序号" width="70" />
|
||||
<el-table-column prop="voucherNo" label="执行凭证号" min-width="220"><template #default="{ row }"><el-link type="primary" @click="openViewer(row)">{{ row.voucherNo }}</el-link></template></el-table-column>
|
||||
<el-table-column prop="plateNo" label="车牌号" min-width="150" />
|
||||
<el-table-column prop="folderName" label="文件夹名称" min-width="160" />
|
||||
<el-table-column prop="matched" label="是否关联运单" width="130"><template #default="{ row }">{{ row.matched === 1 ? '是' : '否' }}</template></el-table-column>
|
||||
<el-table-column prop="processStatus" label="处理状态" width="130" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" />
|
||||
<el-table-column prop="updateTime" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><div class="voucher-folder-page__actions"><el-link v-if="row.matched !== 1" type="primary" @click="openReplace(row)">单个上传</el-link><el-link type="primary" @click="openViewer(row)">查看</el-link><el-link type="danger" @click="removeFolder(row)">删除</el-link></div></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="voucher-folder-page__pagination"><el-pagination v-model:current-page="page.current" v-model:page-size="page.size" :total="page.total" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next, sizes" @current-change="load" @size-change="load" /></div>
|
||||
</section>
|
||||
</basic-container>
|
||||
|
||||
<el-dialog v-model="viewerVisible" title="执行凭证详情" width="960px" destroy-on-close>
|
||||
<div v-loading="viewerLoading" class="voucher-folder-page__viewer"><div class="voucher-folder-page__viewer-info"><span>执行凭证号:{{ viewer.voucherNo || '-' }}</span><span>文件夹名称:{{ viewer.folderName || '-' }}</span><span>是否关联运单:{{ viewer.matched === 1 ? '是' : '否' }}</span><span>配载单号:<el-link v-if="viewer.loadingNo" type="primary" @click="goToLoading(viewer.loadingNo)">{{ viewer.loadingNo }}</el-link><template v-else>-</template></span></div><div v-if="viewerFiles.length" class="voucher-folder-page__images"><el-image v-for="(file, index) in viewerFiles" :key="file.id || file.url || index" :src="file.url" :preview-src-list="viewerFiles.map(item => item.url).filter(Boolean)" :initial-index="index" fit="cover" /></div><el-empty v-else description="暂无凭证文件" /></div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="replaceVisible" title="重新上传" width="680px" destroy-on-close>
|
||||
<div class="voucher-folder-page__replace"><el-upload action="#" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,.bmp,.webp,.zip" :on-change="selectReplace" :on-remove="clearReplace"><el-button type="primary">上传</el-button></el-upload><p>重新上传替换当前未匹配的凭证,支持图片或压缩包,大小不超过50M</p></div>
|
||||
<template #footer><el-button @click="replaceVisible = false">关闭</el-button><el-button type="primary" :loading="replacing" @click="confirmReplace">确认</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import * as api from '@/api/business/voucher-manage';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const voucherId = route.query.id;
|
||||
const loading = ref(false), rows = ref([]), voucher = ref({ voucherBatchNo: route.query.batchNo || '' });
|
||||
const page = reactive({ current: 1, size: 10, total: 0 });
|
||||
const query = reactive({ plateNo: '', matched: undefined });
|
||||
const viewerVisible = ref(false), viewerLoading = ref(false), viewer = ref({}), viewerFiles = ref([]);
|
||||
const replaceVisible = ref(false), replacing = ref(false), replaceRow = ref(), replaceFile = ref();
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.getFolderPage(voucherId, page.current, page.size, query);
|
||||
const data = res.data?.data || {};
|
||||
rows.value = data.records || [];
|
||||
page.total = data.total || 0;
|
||||
if (!voucher.value.voucherBatchNo && rows.value[0]) voucher.value = { voucherBatchNo: rows.value[0].voucherBatchNo };
|
||||
} finally { loading.value = false; }
|
||||
};
|
||||
const search = () => { page.current = 1; load(); };
|
||||
const reset = () => { query.plateNo = ''; query.matched = undefined; search(); };
|
||||
const openViewer = async row => {
|
||||
viewerVisible.value = true; viewerLoading.value = true; viewer.value = row; viewerFiles.value = [];
|
||||
try { const res = await api.getFolderDetail(voucherId, row.plateNo); viewer.value = res.data?.data || row; viewerFiles.value = viewer.value.files || []; } catch (error) { ElMessage.error(error.message || '获取凭证详情失败'); } finally { viewerLoading.value = false; }
|
||||
};
|
||||
const openReplace = row => { replaceRow.value = row; replaceFile.value = undefined; replaceVisible.value = true; };
|
||||
const selectReplace = upload => { replaceFile.value = upload.raw; };
|
||||
const clearReplace = () => { replaceFile.value = undefined; };
|
||||
const confirmReplace = async () => {
|
||||
if (!replaceFile.value) return ElMessage.warning('请选择凭证文件');
|
||||
if (replaceFile.value.size > 50 * 1024 * 1024) return ElMessage.warning('凭证文件大小不能超过50M');
|
||||
replacing.value = true;
|
||||
try { await api.replaceFolder(voucherId, replaceRow.value.plateNo, replaceFile.value); ElMessage.success('上传成功,已重新匹配运单'); replaceVisible.value = false; await load(); } catch (error) { ElMessage.error(error.message || '上传失败'); } finally { replacing.value = false; }
|
||||
};
|
||||
const removeFolder = row => ElMessageBox.confirm(`确认删除车牌”${row.plateNo}”的凭证吗?`, '提示', { type: 'warning' }).then(async () => { await api.removeFolder(voucherId, row.plateNo); ElMessage.success('删除成功'); load(); });
|
||||
const goToLoading = loadingNo => { router.push({ path: '/business/loading-manage', query: { loadingNo } }); };
|
||||
onMounted(() => { if (!voucherId) { ElMessage.error('缺少凭证批次参数'); router.back(); return; } load(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.voucher-folder-page { &__search { padding: 12px 12px 4px; margin-bottom: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.04); } &__search-grid { display: grid; grid-template-columns: 360px 360px 1fr; gap: 8px 24px; } &__search-actions { display: flex; justify-content: flex-end; gap: 8px; } &__table-wrap { padding: 12px; background: #fff; } &__title { padding-bottom: 12px; color: #303133; font-size: 16px; font-weight: 600; } &__actions { display: flex; flex-wrap: wrap; gap: 8px; } &__pagination { display: flex; justify-content: flex-end; padding-top: 12px; } &__viewer { min-height: 360px; } &__viewer-info { display: flex; gap: 28px; padding-bottom: 20px; color: #303133; } &__images { display: grid; grid-template-columns: repeat(5, 140px); gap: 20px; } &__images :deep(.el-image) { width: 140px; height: 100px; border: 6px solid #cfcfcf; cursor: zoom-in; } &__replace { padding: 20px 28px 42px; } &__replace p { margin: 28px 0 0; color: #303133; font-size: 16px; } :deep(.el-table) { --el-table-border-color: #eff1f7; } :deep(.el-table__body tr:nth-child(even)>td) { background: #fafafa; } }
|
||||
@media (max-width: 900px) { .voucher-folder-page__search-grid { grid-template-columns: 1fr 1fr; } .voucher-folder-page__search-actions { grid-column: 1 / -1; } }
|
||||
</style>
|
||||
@@ -22,13 +22,13 @@
|
||||
><el-input v-model="query.waybillBatchNo" clearable placeholder="请输入"
|
||||
/></el-form-item>
|
||||
<template v-if="searchExpanded">
|
||||
<el-form-item label="上传来源"
|
||||
<el-form-item v-if="!isCarrier" label="上传来源"
|
||||
><el-select v-model="query.uploadSource" clearable placeholder="全部"
|
||||
><el-option label="内部" value="内部" /><el-option
|
||||
label="承运商"
|
||||
value="承运商" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="承运商"
|
||||
<el-form-item v-if="!isCarrier" label="承运商"
|
||||
><el-input v-model="query.carrierName" clearable placeholder="请输入"
|
||||
/></el-form-item>
|
||||
<el-form-item label="创建日期"
|
||||
@@ -77,7 +77,8 @@
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="uploadSource" label="上传来源" width="100" /><el-table-column
|
||||
<el-table-column v-if="!isCarrier" prop="uploadSource" label="上传来源" width="100" /><el-table-column
|
||||
v-if="!isCarrier"
|
||||
prop="carrierName"
|
||||
label="承运商名称"
|
||||
min-width="160"
|
||||
@@ -99,13 +100,19 @@
|
||||
width="120"
|
||||
fixed="right"
|
||||
align="center"
|
||||
/><el-table-column label="操作" width="160" fixed="right" align="left"
|
||||
/><el-table-column label="操作" width="200" fixed="right" align="left"
|
||||
><template #default="{ row }"
|
||||
><div class="voucher-manage-page__actions">
|
||||
<el-link
|
||||
v-if="row.processStatus === '处理完成' && row.auditStatus === '待审核'"
|
||||
v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
|
||||
type="primary"
|
||||
>流程</el-link
|
||||
@click="handleAuditPass(row)"
|
||||
>审核通过</el-link
|
||||
><el-link
|
||||
v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
|
||||
type="warning"
|
||||
@click="handleAuditReject(row)"
|
||||
>审核驳回</el-link
|
||||
><el-link v-if="row.processStatus === '处理完成'" type="primary" @click="view(row)"
|
||||
>查看</el-link
|
||||
><el-link
|
||||
@@ -142,63 +149,60 @@
|
||||
</section>
|
||||
</basic-container>
|
||||
|
||||
<el-dialog v-model="detailVisible" width="960px" destroy-on-close>
|
||||
<template #header>
|
||||
<div class="voucher-manage-page__dialog-title">凭证详情</div>
|
||||
</template>
|
||||
<el-descriptions v-loading="detailLoading" :column="2" border class="voucher-manage-page__detail-desc">
|
||||
<el-descriptions-item label="项目名称">
|
||||
{{ detailValue(detailRow.projectName) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="凭证批次号">
|
||||
{{ detailValue(detailRow.voucherBatchNo) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="运单批次号" :span="2">
|
||||
{{ detailValue(detailRow.waybillBatchNo) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="文件名称" :span="2">
|
||||
<el-link v-if="detailRow.fileUrl" type="primary" @click="download(detailRow)">
|
||||
{{ detailValue(detailRow.fileName) }}
|
||||
</el-link>
|
||||
<template v-else>{{ detailValue(detailRow.fileName) }}</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上传来源">
|
||||
{{ detailValue(detailRow.uploadSource) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="承运商名称">
|
||||
{{ detailValue(detailRow.carrierName) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">
|
||||
{{ detailValue(detailRow.processStatus) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">
|
||||
{{ detailValue(detailRow.auditStatus) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="凭证数量">
|
||||
{{ detailValue(detailRow.voucherCount) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已关联运单">
|
||||
{{ detailValue(detailRow.relatedWaybillCount) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="未关联运单">
|
||||
{{ detailValue(detailRow.unRelatedWaybillCount) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">
|
||||
{{ detailValue(detailRow.createUserName) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ detailValue(detailRow.createTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新人">
|
||||
{{ detailValue(detailRow.updateUserName) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">
|
||||
{{ detailValue(detailRow.updateTime) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="执行凭证详情"
|
||||
width="92%"
|
||||
top="5vh"
|
||||
class="voucher-detail-dialog"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading" class="voucher-manage-page__detail">
|
||||
<div class="voucher-manage-page__detail-summary">
|
||||
<div class="voucher-manage-page__detail-field">
|
||||
<span class="voucher-manage-page__detail-label">执行凭证号</span>
|
||||
<span class="voucher-manage-page__detail-value">{{ detailVoucherNo }}</span>
|
||||
</div>
|
||||
<div class="voucher-manage-page__detail-field">
|
||||
<span class="voucher-manage-page__detail-label">文件夹名称</span>
|
||||
<span class="voucher-manage-page__detail-value">{{ detailFolderName }}</span>
|
||||
</div>
|
||||
<div class="voucher-manage-page__detail-field">
|
||||
<span class="voucher-manage-page__detail-label">是否关联运单</span>
|
||||
<span class="voucher-manage-page__detail-value">{{ detailRelatedWaybill }}</span>
|
||||
</div>
|
||||
<div class="voucher-manage-page__detail-field">
|
||||
<span class="voucher-manage-page__detail-label">配载单号</span>
|
||||
<el-link
|
||||
v-if="detailLoadingNo !== '-'"
|
||||
class="voucher-manage-page__detail-value voucher-manage-page__detail-link"
|
||||
type="primary"
|
||||
>
|
||||
{{ detailLoadingNo }}
|
||||
</el-link>
|
||||
<span v-else class="voucher-manage-page__detail-value">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailImages.length" class="voucher-manage-page__detail-images">
|
||||
<el-image
|
||||
v-for="(image, index) in detailImages"
|
||||
:key="image.url || index"
|
||||
class="voucher-manage-page__detail-image"
|
||||
:src="image.url"
|
||||
:preview-src-list="detailImageUrls"
|
||||
:initial-index="index"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
<div v-if="detailOtherFiles.length" class="voucher-manage-page__detail-files">
|
||||
<div v-for="file in detailOtherFiles" :key="file.id || file.url || file.name" class="voucher-manage-page__detail-file">
|
||||
<span class="voucher-manage-page__detail-file-name">{{ file.name }}</span>
|
||||
<el-link v-if="file.url" type="primary" @click="openDetailFile(file)">查看</el-link>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!detailImages.length && !detailOtherFiles.length" description="暂无凭证文件" :image-size="80" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
@@ -217,7 +221,7 @@
|
||||
label-width="auto"
|
||||
class="voucher-manage-page__upload-form"
|
||||
>
|
||||
<el-form-item label="项目名称" prop="projectId"
|
||||
<el-form-item v-if="!isCarrier" label="项目名称" prop="projectId"
|
||||
><el-select
|
||||
v-model="editing.projectId"
|
||||
class="voucher-manage-page__project-select"
|
||||
@@ -231,7 +235,7 @@
|
||||
:label="item.projectName"
|
||||
:value="item.id" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="执行凭证" prop="fileUrl"
|
||||
<el-form-item label="执行凭证" required
|
||||
><el-upload
|
||||
action="#"
|
||||
accept=".zip,.7z"
|
||||
@@ -246,7 +250,7 @@
|
||||
>上传</el-button
|
||||
><template #tip
|
||||
><span class="voucher-manage-page__upload-tip"
|
||||
>支持 zip、7z 格式,采用分片上传,上传中断后可在“上传进度”继续上传。</span
|
||||
>请上传车辆的执行凭证压缩包,支持zip、7z格式。 上传附件大于50M时为超大附件,确认提交后可在“批量补录->上传任务”中查看上传进度。</span
|
||||
></template
|
||||
></el-upload
|
||||
><el-progress
|
||||
@@ -325,8 +329,9 @@
|
||||
ref="batchTableRef"
|
||||
:data="batchRows"
|
||||
border
|
||||
@selection-change="batchSelection = $event"
|
||||
><el-table-column type="selection" width="56" /><el-table-column
|
||||
highlight-current-row
|
||||
@current-change="batchSelection = $event ? [$event] : []"
|
||||
><el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="80" /><el-table-column
|
||||
@@ -337,7 +342,13 @@
|
||||
label="创建时间"
|
||||
min-width="180" /><el-table-column label="运单数" width="120"
|
||||
><template #default="{ row }">{{ row.waybillCount ?? '-' }}</template></el-table-column
|
||||
><el-table-column prop="createUserName" label="创建人" min-width="140"
|
||||
><el-table-column prop="createUserName" label="创建人" min-width="140" /><el-table-column
|
||||
label="操作"
|
||||
width="100"
|
||||
align="center"
|
||||
><template #default="{ row }"
|
||||
><el-link type="primary" @click="selectBatch(row)">选择</el-link></template
|
||||
></el-table-column
|
||||
/></el-table>
|
||||
<div class="voucher-manage-page__pagination">
|
||||
<el-pagination
|
||||
@@ -458,13 +469,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { computed, reactive, ref, watch, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import md5 from 'js-md5';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { getList as getProjectList } from '@/api/business/project-apply';
|
||||
import * as api from '@/api/business/voucher-manage';
|
||||
import SectionCard from '@/components/section-card/main.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 权限判断
|
||||
const permission = computed(() => store.getters.permission);
|
||||
const userInfo = computed(() => store.getters.userInfo);
|
||||
const isAdmin = computed(() => userInfo.value?.authority?.includes('admin'));
|
||||
const hasPermission = code => {
|
||||
if (isAdmin.value) return true;
|
||||
return permission.value?.[code] === true;
|
||||
};
|
||||
|
||||
// 判断当前用户是否为承运商(顶级组织是否为"外部组织")
|
||||
const isCarrier = ref(false);
|
||||
const checkIfCarrier = () => {
|
||||
const userInfoData = userInfo.value || {};
|
||||
const topDeptName = userInfoData.top_dept_name || userInfoData.topDeptName || '';
|
||||
isCarrier.value = topDeptName === '外部组织';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkIfCarrier();
|
||||
});
|
||||
|
||||
const loading = ref(false),
|
||||
rows = ref([]),
|
||||
createRange = ref([]),
|
||||
@@ -834,6 +871,16 @@ const resetBatchQuery = () => {
|
||||
batchPage.current = 1;
|
||||
loadBatches();
|
||||
};
|
||||
const selectBatch = row => {
|
||||
const batches = new Map();
|
||||
selectedBatches.value.forEach(item => {
|
||||
if (!batches.has(item.batchNo)) batches.set(item.batchNo, item);
|
||||
});
|
||||
if (!batches.has(row.batchNo)) batches.set(row.batchNo, row);
|
||||
selectedBatches.value = [...batches.values()];
|
||||
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
|
||||
batchVisible.value = false;
|
||||
};
|
||||
const confirmBatches = () => {
|
||||
const batches = new Map();
|
||||
batchSelection.value.forEach(item => {
|
||||
@@ -935,24 +982,218 @@ const removeRow = row =>
|
||||
});
|
||||
const detailValue = value =>
|
||||
value === undefined || value === null || value === '' ? '-' : value;
|
||||
const view = async row => {
|
||||
detailVisible.value = true;
|
||||
detailLoading.value = true;
|
||||
detailRow.value = { ...row };
|
||||
try {
|
||||
const res = await api.getDetail(row.id);
|
||||
detailRow.value = res.data?.data || row;
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '获取凭证详情失败');
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
const firstDetailValue = (row, keys) => {
|
||||
for (const key of keys) {
|
||||
const value = row?.[key];
|
||||
if (value !== undefined && value !== null && value !== '') return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const detailVoucherNo = computed(() =>
|
||||
detailValue(
|
||||
firstDetailValue(detailRow.value, ['voucherNo', 'executeVoucherNo', 'voucherBatchNo'])
|
||||
)
|
||||
);
|
||||
const detailFolderName = computed(() =>
|
||||
detailValue(
|
||||
firstDetailValue(detailRow.value, [
|
||||
'folderName',
|
||||
'directoryName',
|
||||
'sourceFolderName',
|
||||
'voucherFolderName',
|
||||
'folder',
|
||||
'fileName',
|
||||
])
|
||||
)
|
||||
);
|
||||
const detailRelatedWaybill = computed(() => {
|
||||
const value = firstDetailValue(detailRow.value, [
|
||||
'isRelatedWaybill',
|
||||
'isRelateWaybill',
|
||||
'isRelationWaybill',
|
||||
'relatedWaybill',
|
||||
'relatedWaybillFlag',
|
||||
'hasRelatedWaybill',
|
||||
]);
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'number') return value > 0 ? '是' : '否';
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
if (['1', 'true', 'yes', '是'].includes(value.trim().toLowerCase())) return '是';
|
||||
if (['0', 'false', 'no', '否'].includes(value.trim().toLowerCase())) return '否';
|
||||
return value;
|
||||
}
|
||||
const count = firstDetailValue(detailRow.value, ['relatedWaybillCount', 'waybillCount']);
|
||||
if (count !== undefined && count !== null && count !== '') {
|
||||
return Number(count) > 0 ? '是' : '否';
|
||||
}
|
||||
return firstDetailValue(detailRow.value, ['waybillBatchNo', 'waybillNo']) ? '是' : '-';
|
||||
});
|
||||
const detailLoadingNo = computed(() =>
|
||||
detailValue(
|
||||
firstDetailValue(detailRow.value, [
|
||||
'loadingNo',
|
||||
'loadingCode',
|
||||
'loadBatchNo',
|
||||
'loadingBatchNo',
|
||||
'stowageNo',
|
||||
'stowageCode',
|
||||
'stowageBatchNo',
|
||||
'shipmentNo',
|
||||
'waybillBatchNo',
|
||||
'waybillNo',
|
||||
])
|
||||
)
|
||||
);
|
||||
const imageFieldKeys = [
|
||||
'voucherImages',
|
||||
'voucherImageList',
|
||||
'executeVoucherImages',
|
||||
'executeVoucherImageList',
|
||||
'voucherImgList',
|
||||
'voucherImageUrls',
|
||||
'imageList',
|
||||
'imgList',
|
||||
'imageUrls',
|
||||
'images',
|
||||
'pictureList',
|
||||
'pictures',
|
||||
'photoList',
|
||||
'attachments',
|
||||
'attachmentList',
|
||||
'fileList',
|
||||
];
|
||||
const fileFieldKeys = ['voucherFiles', 'files', 'fileDetails', 'fileList'];
|
||||
const toDetailImageItems = value => {
|
||||
if (Array.isArray(value)) return value.flatMap(item => toDetailImageItems(item));
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim();
|
||||
if (!text) return [];
|
||||
try {
|
||||
return toDetailImageItems(JSON.parse(text));
|
||||
} catch {
|
||||
return text.split(/[,\n;]/).map(item => item.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const nested = value.records || value.list || value.items || value.data || value.urls;
|
||||
if (nested !== undefined) return toDetailImageItems(nested);
|
||||
}
|
||||
return value && typeof value === 'object' ? [value] : [];
|
||||
};
|
||||
const normalizeDetailImage = item => {
|
||||
if (typeof item === 'string') return { url: item, name: item.split('/').pop() };
|
||||
const url =
|
||||
item?.url ||
|
||||
item?.link ||
|
||||
item?.fileUrl ||
|
||||
item?.downloadUrl ||
|
||||
item?.domain ||
|
||||
item?.src ||
|
||||
item?.imageUrl ||
|
||||
item?.path;
|
||||
return url ? { url: String(url), name: item.name || item.fileName || '' } : null;
|
||||
};
|
||||
const normalizeDetailFile = item => {
|
||||
if (typeof item === 'string') {
|
||||
return { name: item.split('/').pop() || item, url: item, fileType: '' };
|
||||
}
|
||||
const url = item?.url || item?.link || item?.fileUrl || item?.downloadUrl || item?.domain || item?.src;
|
||||
const name = item?.fileName || item?.name || item?.entryName || item?.imageName || '-';
|
||||
return {
|
||||
...item,
|
||||
id: item?.id,
|
||||
name,
|
||||
url: url ? String(url) : '',
|
||||
fileType: item?.fileType || '',
|
||||
contentType: item?.contentType || '',
|
||||
};
|
||||
};
|
||||
const isDetailImage = file =>
|
||||
file.fileType === 'image' ||
|
||||
String(file.contentType || '').startsWith('image/') ||
|
||||
/\.(jpe?g|png|bmp|webp|gif)$/i.test(file.name || '');
|
||||
const detailFiles = computed(() => {
|
||||
for (const key of fileFieldKeys) {
|
||||
const value = detailRow.value?.[key];
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
const files = toDetailImageItems(value).map(normalizeDetailFile).filter(Boolean);
|
||||
if (files.length) return files;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const detailImages = computed(() => {
|
||||
if (detailFiles.value.length) return detailFiles.value.filter(file => isDetailImage(file));
|
||||
for (const key of imageFieldKeys) {
|
||||
const value = detailRow.value?.[key];
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
const images = toDetailImageItems(value).map(normalizeDetailImage).filter(Boolean);
|
||||
if (images.length) return images;
|
||||
}
|
||||
const singleImage = normalizeDetailImage(detailRow.value?.voucherImage || detailRow.value?.image);
|
||||
return singleImage ? [singleImage] : [];
|
||||
});
|
||||
const detailOtherFiles = computed(() =>
|
||||
detailFiles.value.filter(file => !isDetailImage(file))
|
||||
);
|
||||
const detailImageUrls = computed(() => detailImages.value.map(image => image.url));
|
||||
const openDetailFile = file => {
|
||||
if (file?.url) window.open(file.url, '_blank');
|
||||
};
|
||||
const view = async row => {
|
||||
router.push({
|
||||
path: '/business/voucher-manage/detail',
|
||||
query: { id: row.id, batchNo: row.voucherBatchNo },
|
||||
});
|
||||
};
|
||||
const download = row => window.open(row.fileUrl, '_blank');
|
||||
const handleAuditPass = row =>
|
||||
ElMessageBox.confirm(`确认审核通过凭证批次"${row.voucherBatchNo}"吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
await api.auditPass(row.id);
|
||||
ElMessage.success('审核通过');
|
||||
load();
|
||||
});
|
||||
const handleAuditReject = row =>
|
||||
ElMessageBox.prompt('请输入驳回原因(可选)', '审核驳回', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /.*/,
|
||||
}).then(async ({ value }) => {
|
||||
await api.auditReject(row.id, value || '');
|
||||
ElMessage.success('审核驳回');
|
||||
load();
|
||||
});
|
||||
load();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:global(.voucher-detail-dialog.el-dialog) {
|
||||
max-width: 996px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.voucher-detail-dialog .el-dialog__header) {
|
||||
padding: 20px 24px;
|
||||
margin-right: 0;
|
||||
//background: #f5f5f5;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
:global(.voucher-detail-dialog .el-dialog__headerbtn) {
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
:global(.voucher-detail-dialog .el-dialog__body) {
|
||||
max-height: calc(100vh - 130px);
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.voucher-manage-page {
|
||||
&__search {
|
||||
padding: 12px 12px 4px;
|
||||
@@ -986,21 +1227,6 @@ load();
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
&__dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #303133;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #409eff;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1025,6 +1251,20 @@ load();
|
||||
margin-left: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
&__upload-progress {
|
||||
margin-top: 12px;
|
||||
:deep(.el-progress__text) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
:deep(.el-progress-bar__outer) {
|
||||
background: #eff1f7;
|
||||
border-radius: 6px;
|
||||
}
|
||||
:deep(.el-progress-bar__inner) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
&__progress-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1076,16 +1316,108 @@ load();
|
||||
:deep(.el-table__body tr:nth-child(even) > td) {
|
||||
background: #fafafa;
|
||||
}
|
||||
&__detail-desc {
|
||||
:deep(.el-descriptions__table) {
|
||||
table-layout: fixed;
|
||||
th, td {
|
||||
width: 50%;
|
||||
}
|
||||
.el-descriptions__label,
|
||||
.el-descriptions__content {
|
||||
width: 50%;
|
||||
}
|
||||
&__detail {
|
||||
min-height: 420px;
|
||||
padding: 40px 46px 68px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
&__detail-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
column-gap: 56px;
|
||||
row-gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
&__detail-field {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
column-gap: 16px;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
&__detail-label,
|
||||
&__detail-value {
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&__detail-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
&__detail-link {
|
||||
justify-self: start;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
&__detail-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 140px);
|
||||
gap: 32px;
|
||||
justify-content: start;
|
||||
}
|
||||
&__detail-image {
|
||||
width: 140px;
|
||||
height: 100px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: #fff;
|
||||
border: 6px solid #cfcfcf;
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
&__detail-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eff1f7;
|
||||
}
|
||||
&__detail-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 28px;
|
||||
}
|
||||
&__detail-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #303133;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.voucher-manage-page {
|
||||
&__detail-images {
|
||||
grid-template-columns: repeat(4, 140px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.voucher-manage-page {
|
||||
&__detail {
|
||||
padding: 28px 24px 40px;
|
||||
}
|
||||
&__detail-summary {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 12px;
|
||||
}
|
||||
&__detail-images {
|
||||
grid-template-columns: repeat(2, minmax(0, 140px));
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<waybill-manage-page
|
||||
:api="api"
|
||||
:config="config"
|
||||
:crud-option="option"
|
||||
:detail-id="$route.query.id"
|
||||
:menu-width="250"
|
||||
standalone-detail-page
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WaybillManagePage from './components/waybill-manage-page.vue';
|
||||
import * as api from '@/api/business/waybill-manage';
|
||||
import { config, option } from '@/option/business/waybill-manage';
|
||||
|
||||
export default {
|
||||
components: { WaybillManagePage },
|
||||
data() {
|
||||
return { api, config, option };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1055,8 +1055,8 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.$router.push({
|
||||
path: '/business/waybill-manage',
|
||||
query: { detailId: waybillId },
|
||||
path: '/business/waybill-manage/detail',
|
||||
query: { id: waybillId },
|
||||
});
|
||||
},
|
||||
restoreDetailSelection() {
|
||||
|
||||
@@ -610,12 +610,30 @@
|
||||
</div>
|
||||
<template v-if="!pageMode" #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button
|
||||
v-if="editable && !recordId"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="draftSaving"
|
||||
@click="handleDraftSave"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
<el-button v-if="editable" type="primary" :loading="saving" @click="handleSave"
|
||||
>提交</el-button
|
||||
>
|
||||
</template>
|
||||
<div v-if="pageMode" class="formal-editor__page-actions">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button
|
||||
v-if="editable && !recordId"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="draftSaving"
|
||||
@click="handleDraftSave"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
<el-button v-if="editable" type="primary" :loading="saving" @click="handleSave">
|
||||
提交
|
||||
</el-button>
|
||||
@@ -964,6 +982,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
draftSaving: false,
|
||||
saving: false,
|
||||
form: createFormalSettlementForm(),
|
||||
sources: [],
|
||||
@@ -2396,6 +2415,20 @@ export default {
|
||||
remark: this.form.remark,
|
||||
};
|
||||
},
|
||||
async handleDraftSave() {
|
||||
this.draftSaving = true;
|
||||
try {
|
||||
await save({
|
||||
...this.buildSavePayload(),
|
||||
approvalStatus: 'draft',
|
||||
});
|
||||
this.$message.success('保存成功');
|
||||
this.visible = false;
|
||||
this.$emit('success');
|
||||
} finally {
|
||||
this.draftSaving = false;
|
||||
}
|
||||
},
|
||||
async handleSave() {
|
||||
await this.$refs.formRef.validate();
|
||||
if (!this.form.sourcePreSettlementIds.length && !this.form.sourceDetailIds.length) {
|
||||
|
||||
@@ -150,7 +150,18 @@
|
||||
>{{ updateName(row.updateResult) }}</el-tag
|
||||
>
|
||||
<span v-else-if="column.prop === 'matchedExternalLineNo'">
|
||||
{{ formatMatchedExternalLineNo(row.matchedExternalLineNo) }}
|
||||
<el-input
|
||||
:model-value="
|
||||
hasMatchedExternal(row) ? formatMatchedExternalLineNo(row.matchedExternalLineNo) : ''
|
||||
"
|
||||
readonly
|
||||
size="small"
|
||||
:placeholder="hasMatchedExternal(row) ? '' : '请选择匹配账单'"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" :disabled="!editable" @click="openExternalMatchDialog(row)" />
|
||||
</template>
|
||||
</el-input>
|
||||
</span>
|
||||
<span v-else-if="column.prop === 'transportType'">
|
||||
{{ transportTypeLabel(row.transportType) }}
|
||||
@@ -422,6 +433,129 @@
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="manualMatchDialog.visible"
|
||||
title="选择未匹配外部账单"
|
||||
width="92%"
|
||||
append-to-body
|
||||
>
|
||||
<el-table v-loading="manualMatchDialog.loading" :data="unmatchedExternalRows" border max-height="520">
|
||||
<el-table-column
|
||||
v-for="column in externalColumns"
|
||||
:key="`manual-${column.prop}`"
|
||||
v-bind="column"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="column.prop === 'transportType'">{{ transportTypeLabel(row.transportType) }}</span>
|
||||
<span v-else-if="column.prop === 'transportQuantity'">{{ formatTransportQuantity(row.transportQuantity) }}</span>
|
||||
<span v-else-if="column.feeItemName">{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}</span>
|
||||
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
|
||||
<span v-else-if="column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'">{{ formatDate(row[column.prop]) }}</span>
|
||||
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
|
||||
<span v-else>{{ displayValue(row[column.prop]) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="handleManualMatch(row)">选择</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="manualMatchDialog.visible = false">取消</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="completionDialog.visible"
|
||||
title="汇总对账结果费用"
|
||||
width="92%"
|
||||
top="4vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="completion-dialog"
|
||||
>
|
||||
<div class="completion-dialog__content">
|
||||
<section class="completion-dialog__section">
|
||||
<div class="completion-dialog__grid">
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">对账单号</span>
|
||||
<span class="completion-dialog__value">{{ displayValue(form.reconciliationNo) }}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">预结算单号</span>
|
||||
<span class="completion-dialog__value">{{
|
||||
displayValue(form.preSettlementNo || form.formalSettlementNo)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">合同编号</span>
|
||||
<span class="completion-dialog__value">{{ displayValue(form.contractNo) }}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">单据周期</span>
|
||||
<span class="completion-dialog__value">{{ completionPeriod }}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">客户/承运商</span>
|
||||
<span class="completion-dialog__value">{{ displayValue(form.customerName) }}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">项目</span>
|
||||
<span class="completion-dialog__value">{{ displayValue(form.projectName) }}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field">
|
||||
<span class="completion-dialog__label">对账模式</span>
|
||||
<span class="completion-dialog__value">{{ reconciliationModeLabel }}</span>
|
||||
</div>
|
||||
<div class="completion-dialog__field completion-dialog__field--remark">
|
||||
<span class="completion-dialog__label">备注</span>
|
||||
<span class="completion-dialog__value">{{ displayValue(form.remark) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="completion-dialog__section completion-dialog__section--fees">
|
||||
<div class="completion-dialog__section-title">费用合计信息</div>
|
||||
<div class="completion-dialog__fee-actions">
|
||||
<el-button type="primary" plain @click="openFeeAdjustment('补款')">补款</el-button>
|
||||
<el-button type="primary" plain @click="openFeeAdjustment('扣款')">扣款</el-button>
|
||||
</div>
|
||||
<el-table :data="completionSummaryRows" border>
|
||||
<el-table-column type="index" label="序号" width="72" align="center" />
|
||||
<el-table-column prop="name" label="费用项目" min-width="150" align="center" />
|
||||
<el-table-column label="原金额(元)" min-width="160" align="right">
|
||||
<template #default="{ row }">{{ formatCompletionMoney(row.originalAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调整金额(元)" min-width="160" align="right">
|
||||
<template #default="{ row }"
|
||||
><span :class="{ 'completion-dialog__negative': row.adjustAmount < 0 }">{{
|
||||
formatCompletionMoney(row.adjustAmount)
|
||||
}}</span></template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="调整后金额(元)" min-width="180" align="right">
|
||||
<template #default="{ row }">{{ formatCompletionMoney(row.afterAmount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="220" />
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="completion-dialog__footer">
|
||||
<el-button type="primary" plain @click="completionDialog.visible = false"
|
||||
>上一步</el-button
|
||||
>
|
||||
<el-button type="primary" :loading="completionDialog.loading" @click="confirmCompletion"
|
||||
>确认</el-button
|
||||
>
|
||||
<el-button @click="completionDialog.visible = false">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -430,6 +564,7 @@ import { mapGetters } from 'vuex';
|
||||
import * as api from '@/api/settlement/transportReconciliation';
|
||||
import * as formalSettlementApi from '@/api/settlement/formalSettlement';
|
||||
import { getDictionary } from '@/api/system/dictbiz';
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import { downloadXls } from '@/utils/util';
|
||||
import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm';
|
||||
import {
|
||||
@@ -450,6 +585,7 @@ export default {
|
||||
emits: ['update:modelValue', 'success'],
|
||||
data() {
|
||||
return {
|
||||
Search,
|
||||
loading: false,
|
||||
saving: false,
|
||||
actionLoading: false,
|
||||
@@ -472,6 +608,8 @@ export default {
|
||||
page: { current: 1, size: 10, total: 0 },
|
||||
},
|
||||
adjustDialog: { visible: false, saving: false, rows: [], reason: '' },
|
||||
manualMatchDialog: { visible: false, loading: false, row: null },
|
||||
completionDialog: { visible: false, loading: false },
|
||||
externalEditing: {},
|
||||
externalEditSnapshots: {},
|
||||
matchingStarted: false,
|
||||
@@ -530,6 +668,9 @@ export default {
|
||||
);
|
||||
},
|
||||
groupedInternalRows() {
|
||||
if (this.form.reconciliationMode === 'cargo') {
|
||||
return this.internalDetails.map(row => ({ ...row, _sourceRows: [row] }));
|
||||
}
|
||||
const groups = new Map();
|
||||
this.internalDetails.forEach((row, index) => {
|
||||
const key = row.documentNo ? String(row.documentNo) : `__row_${index}`;
|
||||
@@ -597,6 +738,72 @@ export default {
|
||||
visibleExternalRows() {
|
||||
return this.externalTab === 'duplicate' ? this.duplicateRows : this.externalDetails;
|
||||
},
|
||||
unmatchedExternalRows() {
|
||||
return this.externalDetails.filter(row => row.matchStatus !== 'matched');
|
||||
},
|
||||
completionPeriod() {
|
||||
const period = this.form.period || this.form.documentPeriod || this.form.settlementPeriod;
|
||||
if (Array.isArray(period) && period.length) return period.filter(Boolean).join(' 至 ');
|
||||
if (typeof period === 'string' && period.trim()) return period;
|
||||
const start =
|
||||
this.form.periodStart ||
|
||||
this.form.periodStartDate ||
|
||||
this.form.startDate ||
|
||||
this.form.billStartDate ||
|
||||
this.form.reconciliationStartDate ||
|
||||
this.form.settlementStartDate;
|
||||
const end =
|
||||
this.form.periodEnd ||
|
||||
this.form.periodEndDate ||
|
||||
this.form.endDate ||
|
||||
this.form.billEndDate ||
|
||||
this.form.reconciliationEndDate ||
|
||||
this.form.settlementEndDate;
|
||||
if (start || end) return [start, end].filter(Boolean).join(' 至 ');
|
||||
return '-';
|
||||
},
|
||||
reconciliationModeLabel() {
|
||||
const labels = { vehicle: '整车总额对账', cargo: '货物明细对账' };
|
||||
return this.form.reconciliationModeName || labels[this.form.reconciliationMode] || '-';
|
||||
},
|
||||
completionSummaryRows() {
|
||||
const source =
|
||||
this.form.feeSummary ||
|
||||
this.form.feeSummaries ||
|
||||
this.form.feeDetails ||
|
||||
this.form.summaryFees;
|
||||
if (Array.isArray(source) && source.length) {
|
||||
return source.map((item, index) => this.normalizeCompletionFee(item, index));
|
||||
}
|
||||
if (source && typeof source === 'object') {
|
||||
return Object.entries(source).map(([name, item], index) =>
|
||||
this.normalizeCompletionFee(
|
||||
typeof item === 'object' ? { ...item, name: item.name || name } : { name, afterAmount: item },
|
||||
index
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const names = new Set([...this.internalFeeItemNames, ...this.externalFeeItemNames]);
|
||||
if (!names.size) names.add('运费');
|
||||
return [...names].map((name, index) => {
|
||||
const originalAmount = this.sumFeeAmount(this.internalDetails, name);
|
||||
const externalAmount = this.sumFeeAmount(this.externalDetails, name);
|
||||
const afterAmount = this.externalDetails.length
|
||||
? externalAmount
|
||||
: name === '运费'
|
||||
? Number(this.form.internalAmount || 0)
|
||||
: originalAmount;
|
||||
return {
|
||||
name,
|
||||
originalAmount,
|
||||
adjustAmount: afterAmount - originalAmount,
|
||||
afterAmount,
|
||||
remark: '',
|
||||
_index: index,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
@@ -928,12 +1135,45 @@ export default {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
serializeInternalRows() {
|
||||
return this.internalDetails.map(row => {
|
||||
const snapshot = { ...row };
|
||||
delete snapshot._sourceRows;
|
||||
delete snapshot._externalKey;
|
||||
delete snapshot.feeItems;
|
||||
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
|
||||
return snapshot;
|
||||
});
|
||||
},
|
||||
serializeExternalRows() {
|
||||
return this.externalDetails.map(row => {
|
||||
const snapshot = { ...row };
|
||||
delete snapshot._sourceRows;
|
||||
delete snapshot._externalKey;
|
||||
delete snapshot.feeItems;
|
||||
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
|
||||
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
|
||||
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
|
||||
return snapshot;
|
||||
});
|
||||
},
|
||||
async handleMatch() {
|
||||
if (!this.currentId) return this.$message.warning('请先保存草稿后再匹配');
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
await api.match(this.currentId);
|
||||
await this.loadDetail();
|
||||
const data = this.unwrapData(
|
||||
await api.matchPreview({
|
||||
id: this.currentId,
|
||||
reconciliationMode: this.form.reconciliationMode,
|
||||
internalDetails: this.serializeInternalRows(),
|
||||
externalDetails: this.serializeExternalRows(),
|
||||
})
|
||||
);
|
||||
this.form = { ...this.form, ...data };
|
||||
this.internalDetails = (data.internalDetails || []).map(row => this.normalizeInternalRow(row));
|
||||
this.externalDetails = (data.externalDetails || []).map((row, index) =>
|
||||
this.normalizeExternalRow(row, index)
|
||||
);
|
||||
this.matchingStarted = true;
|
||||
this.$message.success('匹配完成');
|
||||
} finally {
|
||||
@@ -944,8 +1184,6 @@ export default {
|
||||
await this.$confirm('将以外部账单匹配金额更新内部结算明细,是否继续?', '更新账单', {
|
||||
type: 'warning',
|
||||
});
|
||||
const savedId = await this.handleSave(true);
|
||||
if (!savedId) return;
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
await api.updateByMatch(this.currentId);
|
||||
@@ -963,22 +1201,68 @@ export default {
|
||||
) {
|
||||
return this.$message.warning('差异单数、差异货量和差异金额必须全部为0才可完成对账');
|
||||
}
|
||||
await this.$confirm('完成后对账单不可修改和删除,是否继续?', '完成对账', {
|
||||
type: 'warning',
|
||||
});
|
||||
const savedId = await this.handleSave(true);
|
||||
if (!savedId) return;
|
||||
this.actionLoading = true;
|
||||
this.completionDialog.visible = true;
|
||||
},
|
||||
async confirmCompletion() {
|
||||
if (this.completionDialog.loading) return;
|
||||
this.completionDialog.loading = true;
|
||||
try {
|
||||
await api.complete(this.currentId);
|
||||
await this.loadDetail();
|
||||
this.actionLoading = true;
|
||||
const data = this.unwrapData(
|
||||
await api.completeWithData({
|
||||
id: this.currentId,
|
||||
reconciliationDate: this.form.reconciliationDate,
|
||||
remark: this.form.remark,
|
||||
internalDetails: this.serializeInternalRows(),
|
||||
externalDetails: this.serializeExternalRows(),
|
||||
})
|
||||
);
|
||||
this.form = { ...this.form, ...data };
|
||||
this.internalDetails = (data.internalDetails || []).map(row => this.normalizeInternalRow(row));
|
||||
this.externalDetails = (data.externalDetails || []).map((row, index) =>
|
||||
this.normalizeExternalRow(row, index)
|
||||
);
|
||||
this.$message.success('对账完成');
|
||||
this.completionDialog.visible = false;
|
||||
this.visible = false;
|
||||
this.$emit('success', this.currentId);
|
||||
} finally {
|
||||
this.completionDialog.loading = false;
|
||||
this.actionLoading = false;
|
||||
}
|
||||
},
|
||||
openFeeAdjustment(type) {
|
||||
this.$message.info(`${type}请在结算明细调整中维护`);
|
||||
},
|
||||
sumFeeAmount(rows, name) {
|
||||
return rows.reduce((sum, row) => {
|
||||
const feeItems = row.feeItems || this.parseFeeItems(row.feeItemsJson);
|
||||
const fallback = name === '运费' ? row.freightAmount ?? row.settlementAmount : 0;
|
||||
return sum + Number(feeItems[name] ?? fallback ?? 0);
|
||||
}, 0);
|
||||
},
|
||||
normalizeCompletionFee(item, index) {
|
||||
const originalAmount = Number(
|
||||
item.originalAmount ?? item.original ?? item.originalSettlementAmount ?? 0
|
||||
);
|
||||
const afterAmount = Number(
|
||||
item.afterAmount ?? item.adjustedAmount ?? item.settlementAmount ?? item.totalAmount ?? originalAmount
|
||||
);
|
||||
return {
|
||||
name: item.name || item.feeItem || item.feeItemName || `费用${index + 1}`,
|
||||
originalAmount,
|
||||
adjustAmount: Number(item.adjustAmount ?? item.adjustmentAmount ?? afterAmount - originalAmount),
|
||||
afterAmount,
|
||||
remark: item.remark || item.adjustRemark || '',
|
||||
_index: index,
|
||||
};
|
||||
},
|
||||
formatCompletionMoney(value) {
|
||||
return Number(value || 0).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
},
|
||||
chooseImport() {
|
||||
this.$refs.fileInput?.click();
|
||||
},
|
||||
@@ -987,10 +1271,8 @@ export default {
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
if (!this.currentId) {
|
||||
const savedId = await this.handleSave(true);
|
||||
if (!savedId) return;
|
||||
}
|
||||
const savedId = await this.handleSave(true);
|
||||
if (!savedId) return;
|
||||
const response =
|
||||
this.form.reconciliationMode === 'cargo'
|
||||
? await api.importCargo(this.currentId, file)
|
||||
@@ -1039,23 +1321,96 @@ export default {
|
||||
if (!this.adjustDialog.reason.trim()) return this.$message.warning('请输入调整原因');
|
||||
this.adjustDialog.saving = true;
|
||||
try {
|
||||
for (const row of this.adjustDialog.rows)
|
||||
await api.adjust({
|
||||
const rowsById = new Map(this.adjustDialog.rows.map(row => [row.id, row]));
|
||||
this.internalDetails = this.internalDetails.map(row => {
|
||||
const adjusted = rowsById.get(row.id);
|
||||
if (!adjusted) return row;
|
||||
return {
|
||||
...row,
|
||||
reconciliationId: this.currentId,
|
||||
updateMessage: this.adjustDialog.reason,
|
||||
});
|
||||
transportQuantity: adjusted.transportQuantity,
|
||||
unitPrice: adjusted.unitPrice,
|
||||
mileage: adjusted.mileage,
|
||||
freightAmount: adjusted.freightAmount,
|
||||
feeItems: adjusted.feeItems || this.parseFeeItems(adjusted.feeItemsJson),
|
||||
feeItemsJson: JSON.stringify(adjusted.feeItems || this.parseFeeItems(adjusted.feeItemsJson)),
|
||||
settlementAmount: adjusted.settlementAmount,
|
||||
updateResult: 'manually_adjusted',
|
||||
updateMessage: this.adjustDialog.reason.trim(),
|
||||
};
|
||||
});
|
||||
this.adjustDialog.visible = false;
|
||||
await this.loadDetail();
|
||||
this.$message.success('调整保存成功');
|
||||
this.refreshLocalStats();
|
||||
this.$message.success('调整已暂存');
|
||||
} finally {
|
||||
this.adjustDialog.saving = false;
|
||||
}
|
||||
},
|
||||
async handleUnmatch(row) {
|
||||
const matchedRows = (row._sourceRows || [row]).filter(item => item.matchResult === 'matched');
|
||||
for (const item of matchedRows) await api.unmatch(item.id);
|
||||
await this.loadDetail();
|
||||
matchedRows.forEach(item => {
|
||||
const external = this.externalDetails.find(candidate => candidate.id === item.matchedExternalId);
|
||||
if (external) {
|
||||
external.matchedInternalId = null;
|
||||
external.matchStatus = 'unmatched';
|
||||
external.suspectedDuplicate = false;
|
||||
}
|
||||
item.matchedExternalId = null;
|
||||
item.matchedExternalLineNo = null;
|
||||
item.matchResult = 'unmatched';
|
||||
});
|
||||
this.refreshLocalStats();
|
||||
this.$message.success('取消匹配已暂存');
|
||||
},
|
||||
hasMatchedExternal(row) {
|
||||
return row.matchedExternalLineNo !== null && row.matchedExternalLineNo !== undefined && row.matchedExternalLineNo !== '';
|
||||
},
|
||||
getInternalMatchTarget(row) {
|
||||
const sourceRows = row._sourceRows || [row];
|
||||
return sourceRows.find(item => item.id && item.matchResult !== 'matched') || sourceRows.find(item => item.id);
|
||||
},
|
||||
openExternalMatchDialog(row) {
|
||||
if (!this.editable) return;
|
||||
if (!this.getInternalMatchTarget(row)) return this.$message.warning('当前内部账单明细无法匹配');
|
||||
this.manualMatchDialog.row = row;
|
||||
this.manualMatchDialog.visible = true;
|
||||
},
|
||||
async handleManualMatch(externalRow) {
|
||||
const internalRow = this.getInternalMatchTarget(this.manualMatchDialog.row);
|
||||
if (!internalRow) return this.$message.warning('当前内部账单明细无法匹配');
|
||||
this.manualMatchDialog.loading = true;
|
||||
try {
|
||||
const internal = this.internalDetails.find(item => item.id === internalRow.id);
|
||||
const external = this.externalDetails.find(item => item.id === externalRow.id);
|
||||
if (!internal || !external) return this.$message.warning('匹配明细不存在');
|
||||
if (external.matchedInternalId && external.matchedInternalId !== internal.id) {
|
||||
const oldInternal = this.internalDetails.find(item => item.id === external.matchedInternalId);
|
||||
if (oldInternal) {
|
||||
oldInternal.matchedExternalId = null;
|
||||
oldInternal.matchedExternalLineNo = null;
|
||||
oldInternal.matchResult = 'unmatched';
|
||||
}
|
||||
}
|
||||
if (internal.matchedExternalId && internal.matchedExternalId !== external.id) {
|
||||
const oldExternal = this.externalDetails.find(item => item.id === internal.matchedExternalId);
|
||||
if (oldExternal) {
|
||||
oldExternal.matchedInternalId = null;
|
||||
oldExternal.matchStatus = 'unmatched';
|
||||
oldExternal.suspectedDuplicate = false;
|
||||
}
|
||||
}
|
||||
internal.matchedExternalId = external.id;
|
||||
internal.matchedExternalLineNo = external.externalLineNo;
|
||||
internal.matchResult = 'matched';
|
||||
external.matchedInternalId = internal.id;
|
||||
external.matchStatus = 'matched';
|
||||
external.suspectedDuplicate = false;
|
||||
this.manualMatchDialog.visible = false;
|
||||
this.matchingStarted = true;
|
||||
this.refreshLocalStats();
|
||||
this.$message.success('匹配成功');
|
||||
} finally {
|
||||
this.manualMatchDialog.loading = false;
|
||||
}
|
||||
},
|
||||
internalRowClassName({ row }) {
|
||||
return this.matchingStarted && row.matchResult !== 'matched'
|
||||
@@ -1101,7 +1456,7 @@ export default {
|
||||
this.externalEditing = editing;
|
||||
this.externalEditSnapshots = snapshots;
|
||||
},
|
||||
finishExternalAdjust(row) {
|
||||
async finishExternalAdjust(row) {
|
||||
row.feeItemsJson = JSON.stringify(row.feeItems || {});
|
||||
const key = this.externalRowKey(row);
|
||||
const editing = { ...this.externalEditing };
|
||||
@@ -1110,9 +1465,23 @@ export default {
|
||||
delete snapshots[key];
|
||||
this.externalEditing = editing;
|
||||
this.externalEditSnapshots = snapshots;
|
||||
this.refreshExternalStats();
|
||||
this.refreshLocalStats();
|
||||
this.$message.success('调整已暂存');
|
||||
},
|
||||
refreshExternalStats() {
|
||||
normalizeExternalDateTime(value) {
|
||||
if (!value) return null;
|
||||
const text = String(value);
|
||||
return text.length === 10 ? `${text}T00:00:00` : text;
|
||||
},
|
||||
refreshLocalStats() {
|
||||
const internalQuantity = this.internalDetails.reduce(
|
||||
(total, item) => total + Number(item.transportQuantity || 0),
|
||||
0
|
||||
);
|
||||
const internalAmount = this.internalDetails.reduce(
|
||||
(total, item) => total + Number(item.settlementAmount || 0),
|
||||
0
|
||||
);
|
||||
const externalQuantity = this.externalDetails.reduce(
|
||||
(total, item) => total + Number(item.transportQuantity || 0),
|
||||
0
|
||||
@@ -1121,18 +1490,37 @@ export default {
|
||||
(total, item) => total + Number(item.settlementAmount || 0),
|
||||
0
|
||||
);
|
||||
const matchedCount = this.externalDetails.filter(item => item.matchStatus === 'matched').length;
|
||||
const matchedCount = this.internalDetails.filter(item => item.matchResult === 'matched').length;
|
||||
const internalUnmatched = this.internalDetails.filter(item => item.matchResult !== 'matched').length;
|
||||
const externalUnmatched = this.externalDetails.filter(item => item.matchStatus !== 'matched').length;
|
||||
this.form = {
|
||||
...this.form,
|
||||
internalBillCount: this.internalDetails.length,
|
||||
externalBillCount: this.externalDetails.length,
|
||||
internalQuantity,
|
||||
internalAmount,
|
||||
externalQuantity,
|
||||
externalAmount,
|
||||
differenceQuantity: Math.abs(Number(this.form.internalQuantity || 0) - externalQuantity),
|
||||
differenceAmount: Math.abs(Number(this.form.internalAmount || 0) - externalAmount),
|
||||
differenceCount:
|
||||
Math.abs(this.internalDetails.length - this.externalDetails.length) +
|
||||
Math.min(internalUnmatched, externalUnmatched),
|
||||
differenceQuantity: Math.abs(internalQuantity - externalQuantity),
|
||||
differenceAmount: Math.abs(internalAmount - externalAmount),
|
||||
matchedCount,
|
||||
unmatchedCount: Math.max(Number(this.form.internalBillCount || 0), this.externalDetails.length) - matchedCount,
|
||||
unmatchedCount: internalUnmatched + externalUnmatched,
|
||||
matchStatus:
|
||||
this.internalDetails.length > 0 &&
|
||||
this.internalDetails.length === this.externalDetails.length &&
|
||||
matchedCount === this.internalDetails.length
|
||||
? 'matched'
|
||||
: matchedCount > 0
|
||||
? 'partial'
|
||||
: 'unmatched',
|
||||
};
|
||||
},
|
||||
refreshExternalStats() {
|
||||
this.refreshLocalStats();
|
||||
},
|
||||
matchName(value) {
|
||||
return (
|
||||
{
|
||||
@@ -1303,6 +1691,99 @@ export default {
|
||||
.dialog-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.completion-dialog__content {
|
||||
padding: 4px 8px 0;
|
||||
}
|
||||
.completion-dialog__section {
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #eff1f7;
|
||||
background: #fff;
|
||||
}
|
||||
.completion-dialog__section + .completion-dialog__section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.completion-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px 32px;
|
||||
}
|
||||
.completion-dialog__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.completion-dialog__field--remark {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.completion-dialog__label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 88px;
|
||||
margin-right: 12px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.completion-dialog__value {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
color: #606266;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.completion-dialog__field--remark .completion-dialog__value {
|
||||
white-space: normal;
|
||||
}
|
||||
.completion-dialog__section--fees {
|
||||
position: relative;
|
||||
padding-top: 48px;
|
||||
}
|
||||
.completion-dialog__section-title {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.completion-dialog__section-title::before {
|
||||
width: 4px;
|
||||
height: 22px;
|
||||
margin-right: 8px;
|
||||
background: #409eff;
|
||||
content: '';
|
||||
}
|
||||
.completion-dialog__fee-actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.completion-dialog__fee-actions .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.completion-dialog__negative {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.completion-dialog__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.completion-dialog__footer .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.hidden-file {
|
||||
display: none;
|
||||
}
|
||||
@@ -1336,5 +1817,26 @@ export default {
|
||||
.reconciliation-editor__filter {
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
}
|
||||
.completion-dialog__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.completion-dialog__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.completion-dialog__field--remark {
|
||||
grid-column: auto;
|
||||
}
|
||||
.completion-dialog__section {
|
||||
padding-right: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.completion-dialog__section-title {
|
||||
left: 12px;
|
||||
}
|
||||
.completion-dialog__fee-actions {
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1694,8 +1694,8 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.$router.push({
|
||||
path: '/business/waybill-manage',
|
||||
query: { detailId: row.waybillId },
|
||||
path: '/business/waybill-manage/detail',
|
||||
query: { id: row.waybillId },
|
||||
});
|
||||
},
|
||||
async openDetailDialog(row) {
|
||||
|
||||
+11
-10
@@ -443,18 +443,22 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['userInfo', 'permission']),
|
||||
// 所属组织级联配置:checkStrictly 允许选中任意层级,不必选到末级
|
||||
// 所属组织级联配置:checkStrictly 为 false 时只能选择最后一级(叶子节点)
|
||||
deptCascaderProps() {
|
||||
return {
|
||||
label: 'title',
|
||||
value: 'id',
|
||||
children: 'children',
|
||||
emitPath: true,
|
||||
checkStrictly: true,
|
||||
emitPath: false,
|
||||
checkStrictly: false,
|
||||
expandTrigger: 'click',
|
||||
leaf: (data, node) => {
|
||||
// 判断是否为叶子节点:没有 children 或 children 为空数组
|
||||
return !data.children || data.children.length === 0;
|
||||
},
|
||||
};
|
||||
},
|
||||
// 所属组织为单选级联:form.deptId 仍为数组(提交时 join),此处与 id 路径双向转换
|
||||
// 所属组织为单选级联:form.deptId 为数组(提交时 join),级联返回单个 id 值
|
||||
deptCascaderValue: {
|
||||
get() {
|
||||
const ids = Array.isArray(this.form.deptId)
|
||||
@@ -463,13 +467,10 @@ export default {
|
||||
? [this.form.deptId]
|
||||
: [];
|
||||
const id = ids.find(item => item !== undefined && item !== null && item !== '');
|
||||
return id === undefined ? [] : this.findDeptPath(id) || [id];
|
||||
return id === undefined ? '' : id;
|
||||
},
|
||||
set(path) {
|
||||
const value = Array.isArray(path)
|
||||
? path.filter(item => item !== undefined && item !== null && item !== '')
|
||||
: [];
|
||||
this.form.deptId = value.length ? [value.at(-1)] : [];
|
||||
set(value) {
|
||||
this.form.deptId = value ? [value] : [];
|
||||
this.$refs.userFormRef?.validateField('deptId');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -383,7 +383,10 @@ export default {
|
||||
},
|
||||
openWaybill(row) {
|
||||
if (row.waybillId) {
|
||||
this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.waybillId } });
|
||||
this.$router.push({
|
||||
path: '/business/waybill-manage/detail',
|
||||
query: { id: row.waybillId },
|
||||
});
|
||||
}
|
||||
},
|
||||
decorateRow(row) {
|
||||
|
||||
@@ -347,7 +347,10 @@ export default {
|
||||
},
|
||||
openWaybill(row) {
|
||||
if (row.waybillId) {
|
||||
this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.waybillId } });
|
||||
this.$router.push({
|
||||
path: '/business/waybill-manage/detail',
|
||||
query: { id: row.waybillId },
|
||||
});
|
||||
}
|
||||
},
|
||||
decorateRow(row) {
|
||||
|
||||
@@ -929,7 +929,10 @@
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
<div ref="contactAmap" class="contact-form__map"></div>
|
||||
<div class="map-picker-content">
|
||||
<map-search-results :results="contactMapSearchResults" @select="selectContactMapSearchResult" />
|
||||
<div ref="contactAmap" class="contact-form__map"></div>
|
||||
</div>
|
||||
<div class="contact-form__map-info">
|
||||
<span>{{ contactMapStatus }}</span>
|
||||
<span v-if="contactMapSelected.regionName">
|
||||
@@ -1590,6 +1593,7 @@ export default {
|
||||
contactMapKeyword: '',
|
||||
contactMapStatus: '可搜索地址或点击地图选点',
|
||||
contactMapSelected: {},
|
||||
contactMapSearchResults: [],
|
||||
contactMapTarget: 'contact',
|
||||
contactAmap: null,
|
||||
contactAmapMarker: null,
|
||||
@@ -2995,6 +2999,12 @@ export default {
|
||||
this.ensureContactAmapGeocoder()
|
||||
.then(() => this.runAmapGeocode('location', keyword))
|
||||
.then(result => {
|
||||
this.contactMapSearchResults = (result.geocodes || []).map((item, index) => ({
|
||||
id: item.id || index,
|
||||
name: item.formattedAddress || keyword,
|
||||
address: item.formattedAddress || keyword,
|
||||
location: item.location,
|
||||
}));
|
||||
const point = this.resolveMapPoint(result);
|
||||
if (!point) {
|
||||
this.contactMapStatus = '未找到匹配地址';
|
||||
@@ -3015,6 +3025,11 @@ export default {
|
||||
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
|
||||
});
|
||||
},
|
||||
selectContactMapSearchResult(item) {
|
||||
if (item && item.location) {
|
||||
this.pickContactMapPoint(item.location, item.address || item.name || '');
|
||||
}
|
||||
},
|
||||
pickContactMapPoint(lnglat, keyword) {
|
||||
const longitude = this.getPointLng(lnglat);
|
||||
const latitude = this.getPointLat(lnglat);
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# 凭证管理审核功能增强说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本次更新对凭证管理模块进行了审核流程增强,主要包括:
|
||||
|
||||
1. **承运商信息自动记录**:上传时自动识别并记录承运商信息
|
||||
2. **权限控制**:外部组织用户只能查看自己上传的凭证数据
|
||||
3. **审核流程**:处理完成后自动进入待审核状态,支持审核通过和审核驳回
|
||||
4. **界面优化**:承运商用户隐藏不相关的字段和操作
|
||||
|
||||
## 一、数据库变更
|
||||
|
||||
### 1.1 新增字段
|
||||
|
||||
文件:`/doc/sql/transport/blade_voucher_manage_audit_20260908.sql`
|
||||
|
||||
```sql
|
||||
-- 添加承运商ID字段
|
||||
ALTER TABLE `blade_voucher_manage`
|
||||
ADD COLUMN `carrier_id` bigint DEFAULT NULL COMMENT '承运商ID(组织ID)' AFTER `carrier_name`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `blade_voucher_manage`
|
||||
ADD KEY `idx_voucher_manage_carrier_id` (`carrier_id`),
|
||||
ADD KEY `idx_voucher_manage_audit_status` (`audit_status`);
|
||||
```
|
||||
|
||||
### 1.2 新增菜单权限
|
||||
|
||||
```sql
|
||||
INSERT INTO `blade_menu`
|
||||
(2090000000000000905, 2090000000000000900, 'voucher_manage_audit_pass', '审核通过', ...),
|
||||
(2090000000000000906, 2090000000000000900, 'voucher_manage_audit_reject', '审核驳回', ...);
|
||||
```
|
||||
|
||||
## 二、后端变更
|
||||
|
||||
### 2.1 实体类 (VoucherManage.java)
|
||||
|
||||
新增字段:
|
||||
- `Long carrierId` - 承运商ID(组织ID)
|
||||
|
||||
### 2.2 Controller (VoucherManageController.java)
|
||||
|
||||
新增接口:
|
||||
- `POST /audit-pass` - 审核通过
|
||||
- `POST /audit-reject` - 审核驳回(支持驳回原因)
|
||||
|
||||
### 2.3 Service接口 (IVoucherManageService.java)
|
||||
|
||||
新增方法:
|
||||
```java
|
||||
void auditPass(Long id);
|
||||
void auditReject(Long id, String rejectReason);
|
||||
```
|
||||
|
||||
### 2.4 Service实现 (VoucherManageServiceImpl.java)
|
||||
|
||||
#### 2.4.1 submit方法增强
|
||||
|
||||
- 自动判断用户组织类型
|
||||
- 如果顶级组织是"外部组织",自动设置:
|
||||
- `uploadSource = "承运商"`
|
||||
- `carrierId = 当前用户组织ID`
|
||||
- `carrierName = 当前用户组织名称`
|
||||
- 否则设置为"内部"上传
|
||||
|
||||
#### 2.4.2 buildQuery方法增强
|
||||
|
||||
- 添加权限控制逻辑
|
||||
- 外部组织用户只能查询自己上传的凭证(`createUser = 当前用户ID`)
|
||||
|
||||
#### 2.4.3 processUploadedVoucher方法增强
|
||||
|
||||
- 处理完成后自动设置 `auditStatus = "待审核"`
|
||||
|
||||
#### 2.4.4 新增审核方法
|
||||
|
||||
```java
|
||||
// 审核通过
|
||||
public void auditPass(Long id) {
|
||||
// 验证状态:必须是"处理完成"且"待审核"
|
||||
// 设置 auditStatus = "审核通过"
|
||||
}
|
||||
|
||||
// 审核驳回
|
||||
public void auditReject(Long id, String rejectReason) {
|
||||
// 验证状态:必须是"处理完成"且"待审核"
|
||||
// 设置 auditStatus = "审核驳回"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4.5 新增辅助方法
|
||||
|
||||
```java
|
||||
// 判断是否为外部组织(递归查询顶级组织)
|
||||
private boolean isExternalOrganization(Long deptId);
|
||||
|
||||
// 获取组织名称
|
||||
private String getOrganizationName(Long deptId);
|
||||
```
|
||||
|
||||
### 2.5 Mapper (VoucherManageMapper.java)
|
||||
|
||||
新增查询方法:
|
||||
```java
|
||||
// 查询组织的顶级父组织ID(使用递归CTE)
|
||||
Long selectTopDeptId(@Param("deptId") Long deptId);
|
||||
|
||||
// 查询组织名称
|
||||
String selectDeptName(@Param("deptId") Long deptId);
|
||||
```
|
||||
|
||||
## 三、前端变更
|
||||
|
||||
### 3.1 API接口 (voucher-manage.js)
|
||||
|
||||
新增方法:
|
||||
```javascript
|
||||
export const auditPass = id => request({ url: `${baseUrl}/audit-pass`, method: 'post', params: { id } });
|
||||
export const auditReject = (id, rejectReason) => request({ url: `${baseUrl}/audit-reject`, method: 'post', params: { id, rejectReason } });
|
||||
```
|
||||
|
||||
### 3.2 页面组件 (voucher-manage.vue)
|
||||
|
||||
#### 3.2.1 用户类型判断
|
||||
|
||||
```javascript
|
||||
// 判断当前用户是否为承运商
|
||||
const isCarrier = ref(false);
|
||||
const checkIfCarrier = () => {
|
||||
const userInfo = store.getters.userInfo || {};
|
||||
const topDeptName = userInfo.top_dept_name || userInfo.topDeptName || '';
|
||||
isCarrier.value = topDeptName === '外部组织';
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.2.2 搜索表单优化
|
||||
|
||||
- 承运商用户隐藏"上传来源"和"承运商"搜索条件(`v-if="!isCarrier"`)
|
||||
|
||||
#### 3.2.3 表格列优化
|
||||
|
||||
- 承运商用户隐藏以下列:
|
||||
- 上传来源
|
||||
- 承运商名称
|
||||
|
||||
#### 3.2.4 操作按钮优化
|
||||
|
||||
表格操作列宽度调整为 `200px`,新增审核按钮:
|
||||
|
||||
| 按钮 | 显示条件 | 用户限制 |
|
||||
|------|---------|---------|
|
||||
| 审核通过 | 处理完成 + 待审核 | 仅内部用户 |
|
||||
| 审核驳回 | 处理完成 + 待审核 | 仅内部用户 |
|
||||
| 查看 | 处理完成 | 全部用户 |
|
||||
| 下载 | 处理完成 | 全部用户 |
|
||||
| 更换运单批次 | 未完成或待审核 | 全部用户 |
|
||||
| 删除 | 上传中或审核驳回 | 全部用户 |
|
||||
|
||||
#### 3.2.5 新增审核方法
|
||||
|
||||
```javascript
|
||||
// 审核通过
|
||||
const handleAuditPass = row => {
|
||||
ElMessageBox.confirm(`确认审核通过凭证批次"${row.voucherBatchNo}"吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
await api.auditPass(row.id);
|
||||
ElMessage.success('审核通过');
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
// 审核驳回(支持输入驳回原因)
|
||||
const handleAuditReject = row => {
|
||||
ElMessageBox.prompt('请输入驳回原因(可选)', '审核驳回', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /.*/,
|
||||
}).then(async ({ value }) => {
|
||||
await api.auditReject(row.id, value || '');
|
||||
ElMessage.success('审核驳回');
|
||||
load();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 四、业务流程
|
||||
|
||||
### 4.1 承运商上传流程
|
||||
|
||||
1. 承运商用户登录(顶级组织为"外部组织")
|
||||
2. 上传凭证时,系统自动记录:
|
||||
- 上传来源:承运商
|
||||
- 承运商ID:用户组织ID
|
||||
- 承运商名称:用户组织名称
|
||||
3. 上传完成后,凭证进入"处理中"状态
|
||||
4. 处理完成后,自动变为"待审核"状态
|
||||
5. 承运商用户只能看到自己上传的凭证
|
||||
|
||||
### 4.2 内部用户审核流程
|
||||
|
||||
1. 内部用户可以看到所有凭证(包括内部和承运商上传的)
|
||||
2. 处理完成且待审核的凭证,显示"审核通过"和"审核驳回"按钮
|
||||
3. 点击"审核通过":凭证状态变为"审核通过"
|
||||
4. 点击"审核驳回":可输入驳回原因,凭证状态变为"审核驳回"
|
||||
5. 审核驳回的凭证可以被删除
|
||||
|
||||
### 4.3 状态流转
|
||||
|
||||
```
|
||||
上传中 → 处理中 → 处理完成(自动变为"待审核")
|
||||
↓
|
||||
待审核 → 审核通过
|
||||
↘ 审核驳回 → 可删除
|
||||
```
|
||||
|
||||
## 五、权限控制说明
|
||||
|
||||
### 5.1 数据权限
|
||||
|
||||
| 用户类型 | 可见范围 |
|
||||
|---------|---------|
|
||||
| 内部用户 | 全部凭证 |
|
||||
| 承运商用户 | 仅自己上传的凭证 |
|
||||
|
||||
### 5.2 功能权限
|
||||
|
||||
| 功能 | 内部用户 | 承运商用户 |
|
||||
|-----|---------|-----------|
|
||||
| 批量导入凭证 | ✓ | ✓ |
|
||||
| 查看凭证 | ✓ | ✓(仅自己的) |
|
||||
| 下载凭证 | ✓ | ✓(仅自己的) |
|
||||
| 更换运单批次 | ✓ | ✓(仅自己的) |
|
||||
| 删除凭证 | ✓ | ✓(仅自己的) |
|
||||
| 审核通过 | ✓ | ✗ |
|
||||
| 审核驳回 | ✓ | ✗ |
|
||||
| 查看上传来源字段 | ✓ | ✗ |
|
||||
| 查看承运商字段 | ✓ | ✗ |
|
||||
|
||||
## 六、部署步骤
|
||||
|
||||
### 6.1 数据库部署
|
||||
|
||||
执行SQL脚本:
|
||||
```bash
|
||||
mysql -u用户名 -p数据库名 < blade_voucher_manage_audit_20260908.sql
|
||||
```
|
||||
|
||||
### 6.2 后端部署
|
||||
|
||||
1. 编译后端代码
|
||||
2. 重启 `blade-transport` 服务
|
||||
|
||||
### 6.3 前端部署
|
||||
|
||||
1. 构建前端代码:`pnpm run build:prod`
|
||||
2. 部署到Web服务器
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
1. **数据兼容性**:现有凭证的 `carrier_id` 字段为 `NULL`,不影响现有功能
|
||||
2. **权限配置**:需要为相关角色分配"审核通过"和"审核驳回"权限
|
||||
3. **组织判断**:依赖 `blade_dept` 表的 `dept_name` 字段,顶级组织必须命名为"外部组织"
|
||||
4. **审核状态**:只有"处理完成"且"待审核"的凭证才能审核
|
||||
5. **用户信息**:前端需要确保 `store.getters.userInfo` 中包含 `top_dept_name` 或 `topDeptName` 字段
|
||||
|
||||
## 八、测试建议
|
||||
|
||||
### 8.1 功能测试
|
||||
|
||||
- [ ] 内部用户上传凭证,验证上传来源为"内部"
|
||||
- [ ] 承运商用户上传凭证,验证上传来源为"承运商",承运商信息自动记录
|
||||
- [ ] 承运商用户只能看到自己上传的凭证
|
||||
- [ ] 内部用户可以看到所有凭证
|
||||
- [ ] 凭证处理完成后自动变为"待审核"状态
|
||||
- [ ] 内部用户可以审核通过凭证
|
||||
- [ ] 内部用户可以审核驳回凭证
|
||||
- [ ] 承运商用户看不到审核按钮
|
||||
- [ ] 承运商用户看不到"上传来源"和"承运商名称"列
|
||||
|
||||
### 8.2 权限测试
|
||||
|
||||
- [ ] 承运商用户无法访问 `/audit-pass` 接口
|
||||
- [ ] 承运商用户无法访问 `/audit-reject` 接口
|
||||
- [ ] 承运商用户无法看到其他承运商的凭证
|
||||
|
||||
### 8.3 异常测试
|
||||
|
||||
- [ ] 审核非"待审核"状态的凭证,验证错误提示
|
||||
- [ ] 审核非"处理完成"状态的凭证,验证错误提示
|
||||
- [ ] 组织信息缺失时,系统正常运行
|
||||
|
||||
---
|
||||
|
||||
**修改日期**:2026-09-08
|
||||
**修改人**:Claude
|
||||
**版本**:v1.0
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
# 运输计划导入增强校验说明
|
||||
|
||||
## 修改日期
|
||||
2026-09-08
|
||||
|
||||
## 一、导入逻辑变更
|
||||
|
||||
### 原有逻辑
|
||||
- 逐条导入,遇错即停
|
||||
- 部分数据可能已入库
|
||||
- 错误信息简单
|
||||
|
||||
### 新逻辑(参考 /base/port-terminal)
|
||||
1. **第一阶段:全部校验**
|
||||
- 先校验所有数据
|
||||
- 收集所有错误信息
|
||||
- 不进行任何数据库操作
|
||||
|
||||
2. **第二阶段:批量导入**
|
||||
- 仅当所有数据校验通过后才导入
|
||||
- 任何一条数据有错误,全部回滚
|
||||
- 保证数据一致性
|
||||
|
||||
3. **错误处理**
|
||||
- 导出包含错误信息的Excel
|
||||
- 每条错误数据标注具体错误原因
|
||||
- 支持多个错误信息(编号列表)
|
||||
|
||||
## 二、详细校验规则
|
||||
|
||||
### 1. 必填字段校验
|
||||
|
||||
| 字段 | 校验规则 | 错误提示 |
|
||||
|------|---------|---------|
|
||||
| *计划名称 | 不能为空 | 计划名称不能为空 |
|
||||
| *运输类型 | 不能为空 | 运输类型不能为空 |
|
||||
| *发货地址 | 不能为空 | 发货地址不能为空 |
|
||||
| *到货地址 | 不能为空 | 到货地址不能为空 |
|
||||
| *货物类型 | 不能为空 | 货物类型不能为空 |
|
||||
|
||||
### 2. 枚举值校验
|
||||
|
||||
#### 运输类型
|
||||
**允许值:**
|
||||
- 公路整车
|
||||
- 公路配载/零担
|
||||
- 铁路运输
|
||||
- 水路运输
|
||||
- 跨境海运
|
||||
- 航空运输
|
||||
|
||||
**错误提示:** 运输类型需系统枚举值(公路整车、公路配载/零担、铁路运输、水路运输、跨境海运、航空运输)
|
||||
|
||||
#### 计量单位
|
||||
**常用值:**
|
||||
- 吨、千克、立方米、件、车、箱、托盘、个、套、台
|
||||
|
||||
**错误提示:** 计量单位不存在
|
||||
|
||||
### 3. 唯一性校验
|
||||
|
||||
#### 计划名称(当前组织下不重复)
|
||||
- 检查本次导入数据中的重复
|
||||
- 检查数据库中当前组织的重复
|
||||
- **错误提示:**
|
||||
- 计划名称在导入数据中重复
|
||||
- 计划名称在当前组织下已存在
|
||||
|
||||
### 4. 格式校验
|
||||
|
||||
#### 联系电话
|
||||
**规则:** 11位数字
|
||||
- 发货联系人电话
|
||||
- 收货联系人电话
|
||||
|
||||
**错误提示:**
|
||||
- 发货联系人电话格式不正确(需11位数字)
|
||||
- 收货联系人电话格式不正确(需11位数字)
|
||||
|
||||
#### 日期时间
|
||||
**格式:** YYYY-MM-DD
|
||||
- 计划开始时间(非必填)
|
||||
- 计划结束时间(非必填)
|
||||
|
||||
**逻辑校验:** 计划结束时间不得早于计划开始时间
|
||||
|
||||
**错误提示:**
|
||||
- 计划开始时间格式必须为 YYYY-MM-DD
|
||||
- 计划结束时间格式必须为 YYYY-MM-DD
|
||||
- 计划结束时间不得早于计划开始时间
|
||||
|
||||
### 5. 数值校验
|
||||
|
||||
#### 数量
|
||||
**规则:** 必须为正数或零
|
||||
**错误提示:** 数量必须为正数
|
||||
|
||||
#### 里程(km)
|
||||
**规则:** 必须为正数或零
|
||||
**错误提示:** 里程必须为正数
|
||||
|
||||
### 6. 长度校验
|
||||
|
||||
| 字段 | 最大长度 | 错误提示 |
|
||||
|------|---------|---------|
|
||||
| 计划名称 | 255字符 | 计划名称不能超过255个字符 |
|
||||
| 发货地址 | 255字符 | 发货地址不能超过255个字符 |
|
||||
| 到货地址 | 255字符 | 到货地址不能超过255个字符 |
|
||||
| 发货联系人 | 255字符 | 发货联系人不能超过255个字符 |
|
||||
| 收货联系人 | 255字符 | 收货联系人不能超过255个字符 |
|
||||
| 同一计划标识号 | 255字符 | 同一计划标识号不能超过255个字符 |
|
||||
| 备注 | 500字符 | 备注不能超过500个字符 |
|
||||
|
||||
### 7. 同一计划标识号校验
|
||||
|
||||
**用途:** 当需要导入的计划的货物包含多个时,需拆分多行,并填写该标识,来标记多行为同一计划
|
||||
|
||||
**校验规则:**
|
||||
- 同一标识号下的所有记录应保持以下字段一致:
|
||||
- 计划名称
|
||||
- 运输类型
|
||||
- 发货地址
|
||||
- 到货地址
|
||||
|
||||
**警告提示:** 注意:同一计划标识号应保持计划名称、运输类型、发货地址、到货地址一致
|
||||
|
||||
### 8. 地址匹配校验(警告级别)
|
||||
|
||||
**规则:** 地址应能匹配到系统地址库
|
||||
|
||||
**警告提示:**
|
||||
- 警告:发货地址未匹配到地址库,轨迹回放将受影响
|
||||
- 警告:到货地址未匹配到地址库,轨迹回放将受影响
|
||||
|
||||
**说明:** 此为警告级别,不阻止导入,但会影响后续功能
|
||||
|
||||
## 三、错误信息格式
|
||||
|
||||
### 单条错误
|
||||
```
|
||||
1. 计划名称不能为空
|
||||
```
|
||||
|
||||
### 多条错误
|
||||
```
|
||||
1. 计划名称不能为空
|
||||
2. 运输类型需系统枚举值(公路整车、公路配载/零担、铁路运输、水路运输、跨境海运、航空运输)
|
||||
3. 发货联系人电话格式不正确(需11位数字)
|
||||
```
|
||||
|
||||
## 四、导入流程
|
||||
|
||||
### 用户操作流程
|
||||
1. 下载导入模板
|
||||
2. 填写计划数据
|
||||
3. 上传Excel文件
|
||||
4. 等待校验结果
|
||||
|
||||
### 系统处理流程
|
||||
|
||||
#### 情况1:所有数据校验通过
|
||||
```
|
||||
解析Excel → 全部校验(通过) → 批量导入 → 提示成功
|
||||
```
|
||||
|
||||
#### 情况2:存在校验错误
|
||||
```
|
||||
解析Excel → 全部校验(失败) → 生成错误Excel → 下载错误明细
|
||||
```
|
||||
- 不会导入任何数据
|
||||
- 用户下载包含错误信息的Excel
|
||||
- 修正后重新导入
|
||||
|
||||
## 五、后端实现要点
|
||||
|
||||
### 1. 两阶段提交
|
||||
```java
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<TransportPlanImportExcel> importTransportPlan(...) {
|
||||
// 第一阶段:全部校验
|
||||
Map<Integer, TransportPlanImportExcel> errorMap = new TreeMap<>();
|
||||
for (int index = 0; index < data.size(); index++) {
|
||||
List<String> errors = validateImportExcel(excel, ...);
|
||||
if (Func.isNotEmpty(errors)) {
|
||||
excel.setErrorMessage(formatErrorMessage(errors));
|
||||
errorMap.put(index, excel);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有错误,回滚事务
|
||||
if (Func.isNotEmpty(errorMap)) {
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return new ArrayList<>(errorMap.values());
|
||||
}
|
||||
|
||||
// 第二阶段:批量导入
|
||||
for (TransportPlan plan : importPlans) {
|
||||
save(plan);
|
||||
}
|
||||
|
||||
return new ArrayList<>();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 错误收集器
|
||||
- 使用 `TreeMap` 保持顺序
|
||||
- 索引作为key,保持与Excel行号对应
|
||||
- 错误信息格式化为编号列表
|
||||
|
||||
### 3. 校验方法
|
||||
```java
|
||||
private List<String> validateImportExcel(...) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
// 收集所有错误,不立即抛出异常
|
||||
if (condition) {
|
||||
errors.add("错误信息");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
```
|
||||
|
||||
## 六、前端修改(transport-plan-import.vue)
|
||||
|
||||
### 无需修改
|
||||
前端保持原有逻辑,后端返回的错误Excel会自动触发下载
|
||||
|
||||
## 七、测试用例
|
||||
|
||||
### 测试用例1:必填字段缺失
|
||||
**输入:** 计划名称为空
|
||||
**预期:**
|
||||
- 不导入任何数据
|
||||
- 错误信息:计划名称不能为空
|
||||
|
||||
### 测试用例2:运输类型枚举值错误
|
||||
**输入:** 运输类型 = "陆运"
|
||||
**预期:**
|
||||
- 不导入任何数据
|
||||
- 错误信息:运输类型需系统枚举值
|
||||
|
||||
### 测试用例3:电话格式错误
|
||||
**输入:** 发货联系人电话 = "12345"
|
||||
**预期:**
|
||||
- 不导入任何数据
|
||||
- 错误信息:发货联系人电话格式不正确(需11位数字)
|
||||
|
||||
### 测试用例4:计划名称重复
|
||||
**输入:** 两条数据的计划名称相同
|
||||
**预期:**
|
||||
- 不导入任何数据
|
||||
- 错误信息:计划名称在导入数据中重复
|
||||
|
||||
### 测试用例5:部分数据错误
|
||||
**输入:** 10条数据,第5条有错误
|
||||
**预期:**
|
||||
- 不导入任何数据(包括前4条正确的)
|
||||
- 仅第5条有错误信息
|
||||
|
||||
### 测试用例6:多个错误
|
||||
**输入:** 一条数据同时缺少计划名称和运输类型
|
||||
**预期:**
|
||||
```
|
||||
1. 计划名称不能为空
|
||||
2. 运输类型不能为空
|
||||
```
|
||||
|
||||
### 测试用例7:同一计划标识号
|
||||
**输入:** 3条数据,同一标识号,货物信息不同
|
||||
**预期:**
|
||||
- 可以导入
|
||||
- 3条记录关联到同一计划标识号
|
||||
|
||||
## 八、与 port-terminal 导入的对比
|
||||
|
||||
| 特性 | port-terminal | transport-plan |
|
||||
|------|---------------|----------------|
|
||||
| 两阶段校验 | ✓ | ✓ |
|
||||
| 错误即回滚 | ✓ | ✓ |
|
||||
| 错误信息编号 | ✓ | ✓ |
|
||||
| 唯一性校验 | ✓ | ✓ |
|
||||
| 枚举值校验 | ✓ | ✓ |
|
||||
| 格式校验 | ✓ | ✓ |
|
||||
| 批量导入 | ✓ | ✓ |
|
||||
|
||||
## 九、后续优化建议
|
||||
|
||||
1. **地址匹配校验**
|
||||
- 实现发货地址和到货地址的地址库匹配
|
||||
- 返回警告信息(不阻止导入)
|
||||
|
||||
2. **货物类型智能匹配**
|
||||
- 根据货物名称自动匹配货物类型
|
||||
- 未匹配到时归为"其他"类
|
||||
|
||||
3. **同一计划标识号一致性校验**
|
||||
- 完整实现同一标识号的字段一致性检查
|
||||
- 在第一阶段校验中完成
|
||||
|
||||
4. **计量单位字典化**
|
||||
- 从数据字典读取有效的计量单位列表
|
||||
- 支持自定义扩展
|
||||
|
||||
5. **导入预览**
|
||||
- 前端增加导入预览功能
|
||||
- 用户可在导入前查看解析结果
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# 运输计划导入模板修改说明
|
||||
|
||||
## 修改日期
|
||||
2026-09-08
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 一、模板字段调整
|
||||
|
||||
#### 新增字段
|
||||
1. **里程(km)** - 非必填,数值类型
|
||||
2. **同一计划标识号** - 非必填,文本类型,用于标识同一计划的多条记录
|
||||
|
||||
#### 移除字段
|
||||
1. 品牌
|
||||
2. 物料编码
|
||||
3. 设备编码
|
||||
|
||||
#### 字段名称调整
|
||||
| 原字段名 | 新字段名 |
|
||||
|---------|---------|
|
||||
| 运输方式 | 运输类型 |
|
||||
| 数量单位 | 计量单位 |
|
||||
| 发货联系方式 | 发货联系人电话 |
|
||||
| 收货地址 | 到货地址 |
|
||||
| 收货联系方式 | 收货联系人电话 |
|
||||
| 计划开始日期 | 计划开始时间 |
|
||||
| 计划结束日期 | 计划结束时间 |
|
||||
|
||||
#### 必填字段调整
|
||||
调整后的必填字段(带 * 号):
|
||||
- *计划名称
|
||||
- *运输类型
|
||||
- *发货地址
|
||||
- *到货地址
|
||||
- *货物类型
|
||||
|
||||
**注意**:计划开始时间和计划结束时间改为非必填
|
||||
|
||||
### 二、完整字段列表(按顺序)
|
||||
|
||||
```
|
||||
序号 | *计划名称 | *运输类型 | *发货地址 | 发货联系人 | 发货联系人电话 |
|
||||
*到货地址 | 收货联系人 | 收货联系人电话 | 货物名称 | *货物类型 |
|
||||
数量 | 计量单位 | 包装 | 规格 | 型号 | 里程(km) |
|
||||
计划开始时间 | 计划结束时间 | 备注 | 同一计划标识号
|
||||
```
|
||||
|
||||
### 三、前端修改文件
|
||||
|
||||
1. **src/views/business/transport-plan-import.vue**
|
||||
- 更新 `importColumns` 数组(Excel列映射)
|
||||
- 更新 `previewColumns` 数组(预览表格列定义)
|
||||
- 调整必填字段验证逻辑
|
||||
- 将计划开始/结束时间改为非必填
|
||||
- 添加里程字段的非负数验证
|
||||
- 调整表格最小宽度为 2400px
|
||||
|
||||
### 四、后端修改文件
|
||||
|
||||
1. **TransportPlan.java** (实体类)
|
||||
- 新增 `mileage` 字段(BigDecimal 类型)
|
||||
- 新增 `planGroupId` 字段(String 类型)
|
||||
|
||||
2. **TransportPlanImportExcel.java** (导入Excel实体)
|
||||
- 调整所有字段顺序和名称
|
||||
- 移除 brand、materialCode、deviceCode 字段
|
||||
- 新增 mileage、planGroupId 字段
|
||||
- 更新所有 @ExcelProperty 注解
|
||||
|
||||
3. **TransportPlanServiceImpl.java** (服务实现)
|
||||
- 修改 `parseImportDate` 方法,支持空值(非必填)
|
||||
- 修改 `validateImportExcel` 方法
|
||||
- 更新字段名称验证
|
||||
- 移除计划日期必填验证
|
||||
- 添加里程非负数验证
|
||||
- 调整字段长度限制(计划名称从50改为255)
|
||||
- 修改 `importTransportPlan` 方法
|
||||
- 支持计划时间为空
|
||||
- 添加 mileage 和 planGroupId 字段赋值
|
||||
- 修改 `importGoods` 方法,移除不再需要的字段
|
||||
- 修改 `prepare` 方法,添加 planGroupId 字段处理
|
||||
- 修改 `validate` 方法,添加新字段验证
|
||||
|
||||
### 五、数据库修改
|
||||
|
||||
**SQL文件位置**: `/doc/sql/transport/blade_transport_plan_add_fields_20260908.sql`
|
||||
|
||||
```sql
|
||||
-- 添加里程字段
|
||||
ALTER TABLE `blade_transport_plan`
|
||||
ADD COLUMN `mileage` decimal(10,2) DEFAULT NULL COMMENT '里程(km)' AFTER `remark`;
|
||||
|
||||
-- 添加同一计划标识号字段
|
||||
ALTER TABLE `blade_transport_plan`
|
||||
ADD COLUMN `plan_group_id` varchar(100) DEFAULT NULL COMMENT '同一计划标识号' AFTER `mileage`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `blade_transport_plan`
|
||||
ADD INDEX `idx_transport_plan_group_id` (`plan_group_id`) USING BTREE;
|
||||
```
|
||||
|
||||
### 六、使用说明
|
||||
|
||||
1. **数据库升级**:执行 SQL 迁移脚本添加新字段
|
||||
2. **后端部署**:重新编译打包后端服务
|
||||
3. **前端部署**:重新构建前端项目
|
||||
4. **模板下载**:用户可通过导入页面下载最新模板
|
||||
|
||||
### 七、兼容性说明
|
||||
|
||||
- 新字段为非必填,不影响现有数据
|
||||
- 旧模板导入时新字段为空值
|
||||
- 计划时间改为非必填,提升导入灵活性
|
||||
- 同一计划标识号可用于批量导入时关联多条计划记录
|
||||
|
||||
### 八、测试要点
|
||||
|
||||
1. 验证新模板下载功能
|
||||
2. 测试必填字段校验
|
||||
3. 测试非必填字段的空值处理
|
||||
4. 测试里程字段的数值验证
|
||||
5. 测试同一计划标识号的查询和分组
|
||||
6. 测试日期字段的非必填场景
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# 运输计划导入增强功能 - 快速部署指南
|
||||
|
||||
## 一、修改文件清单
|
||||
|
||||
### 前端文件(tms-erp-web)
|
||||
1. ✅ `src/views/business/transport-plan-import.vue` - 导入页面(已更新字段)
|
||||
|
||||
### 后端文件(tms-erp-api)
|
||||
1. ✅ `blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportPlan.java` - 实体类(新增字段)
|
||||
2. ✅ `blade-service/blade-transport/src/main/java/org/springblade/transport/excel/TransportPlanImportExcel.java` - 导入Excel实体
|
||||
3. ✅ `blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java` - 服务实现(核心逻辑)
|
||||
|
||||
### 数据库脚本
|
||||
1. ✅ `doc/sql/transport/blade_transport_plan_add_fields_20260908.sql` - 字段迁移脚本
|
||||
|
||||
## 二、部署步骤
|
||||
|
||||
### 步骤1:数据库升级(必须最先执行)
|
||||
|
||||
```bash
|
||||
cd /Users/liangxin/Project/JAVA/tms-erp-api
|
||||
mysql -u用户名 -p密码 数据库名 < doc/sql/transport/blade_transport_plan_add_fields_20260908.sql
|
||||
```
|
||||
|
||||
**验证:**
|
||||
```sql
|
||||
DESC blade_transport_plan;
|
||||
-- 应该能看到 mileage 和 plan_group_id 两个新字段
|
||||
```
|
||||
|
||||
### 步骤2:后端编译打包
|
||||
|
||||
```bash
|
||||
cd /Users/liangxin/Project/JAVA/tms-erp-api
|
||||
mvn clean package -DskipTests
|
||||
```
|
||||
|
||||
**验证编译结果:**
|
||||
- 检查 `blade-service/blade-transport/target/` 目录是否有 jar 包生成
|
||||
|
||||
### 步骤3:前端构建
|
||||
|
||||
```bash
|
||||
cd /Users/liangxin/Project/Html/web/tms-erp-web
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### 步骤4:部署后端服务
|
||||
|
||||
停止运输服务 → 替换 jar 包 → 启动服务
|
||||
|
||||
### 步骤5:部署前端
|
||||
|
||||
将 `dist` 目录部署到Web服务器
|
||||
|
||||
## 三、功能测试清单
|
||||
|
||||
### 测试1:模板下载
|
||||
- [ ] 访问 `/business/transport-plan/import` 页面
|
||||
- [ ] 点击"下载模板"按钮
|
||||
- [ ] 验证Excel模板字段顺序和标题正确
|
||||
|
||||
**预期字段顺序:**
|
||||
```
|
||||
序号 | *计划名称 | *运输类型 | *发货地址 | 发货联系人 | 发货联系人电话 |
|
||||
*到货地址 | 收货联系人 | 收货联系人电话 | 货物名称 | *货物类型 |
|
||||
数量 | 计量单位 | 包装 | 规格 | 型号 | 里程(km) |
|
||||
计划开始时间 | 计划结束时间 | 备注 | 同一计划标识号
|
||||
```
|
||||
|
||||
### 测试2:必填字段校验
|
||||
**测试数据:** 计划名称为空
|
||||
- [ ] 上传Excel
|
||||
- [ ] 系统提示导入失败
|
||||
- [ ] 下载错误Excel
|
||||
- [ ] 验证错误信息:`1. 计划名称不能为空`
|
||||
|
||||
### 测试3:运输类型枚举校验
|
||||
**测试数据:** 运输类型 = "陆运"(非枚举值)
|
||||
- [ ] 上传Excel
|
||||
- [ ] 系统提示导入失败
|
||||
- [ ] 验证错误信息包含枚举值列表
|
||||
|
||||
### 测试4:电话格式校验
|
||||
**测试数据:** 发货联系人电话 = "12345"
|
||||
- [ ] 上传Excel
|
||||
- [ ] 系统提示导入失败
|
||||
- [ ] 验证错误信息:`发货联系人电话格式不正确(需11位数字)`
|
||||
|
||||
### 测试5:计划名称唯一性
|
||||
**测试数据:** 两条记录使用相同的计划名称
|
||||
- [ ] 上传Excel
|
||||
- [ ] 系统提示导入失败
|
||||
- [ ] 验证错误信息:`计划名称在导入数据中重复`
|
||||
|
||||
### 测试6:日期逻辑校验
|
||||
**测试数据:** 计划结束时间 < 计划开始时间
|
||||
- [ ] 上传Excel
|
||||
- [ ] 系统提示导入失败
|
||||
- [ ] 验证错误信息:`计划结束时间不得早于计划开始时间`
|
||||
|
||||
### 测试7:多个错误显示
|
||||
**测试数据:** 一条记录缺少多个必填字段
|
||||
- [ ] 上传Excel
|
||||
- [ ] 验证错误信息以编号列表形式展示
|
||||
```
|
||||
1. 计划名称不能为空
|
||||
2. 运输类型不能为空
|
||||
3. 发货地址不能为空
|
||||
```
|
||||
|
||||
### 测试8:部分数据有错不导入全部
|
||||
**测试数据:** 10条记录,第5条有错误
|
||||
- [ ] 上传Excel
|
||||
- [ ] 验证:数据库中没有任何记录被导入
|
||||
- [ ] 验证:错误Excel中仅第5条有错误信息
|
||||
|
||||
### 测试9:新增字段功能
|
||||
**测试数据:** 填写里程和同一计划标识号
|
||||
- [ ] 上传Excel(所有数据正确)
|
||||
- [ ] 验证导入成功
|
||||
- [ ] 查看计划详情,验证里程和同一计划标识号已保存
|
||||
|
||||
### 测试10:同一计划标识号关联
|
||||
**测试数据:** 3条记录使用相同的同一计划标识号
|
||||
- [ ] 上传Excel(所有数据正确)
|
||||
- [ ] 验证导入成功
|
||||
- [ ] 查询时可通过同一计划标识号筛选出这3条记录
|
||||
|
||||
### 测试11:正常导入
|
||||
**测试数据:** 完整正确的数据
|
||||
```
|
||||
计划名称:测试计划001
|
||||
运输类型:公路整车
|
||||
发货地址:广西壮族自治区柳州市柳北区鹧鸪江路15号桂中海迅物流园
|
||||
发货联系人:周经理
|
||||
发货联系人电话:13800000001
|
||||
到货地址:浙江省宁波市北仑区集翔路8号舟山港
|
||||
收货联系人:王经理
|
||||
收货联系人电话:13900000001
|
||||
货物名称:石灰石
|
||||
货物类型:石灰石
|
||||
数量:100
|
||||
计量单位:吨
|
||||
里程(km):850
|
||||
```
|
||||
- [ ] 上传Excel
|
||||
- [ ] 验证导入成功提示
|
||||
- [ ] 进入运输计划列表验证数据已创建
|
||||
|
||||
## 四、常见问题排查
|
||||
|
||||
### 问题1:编译失败
|
||||
**症状:** Maven 编译报错
|
||||
**排查:**
|
||||
```bash
|
||||
# 检查 Java 版本
|
||||
java -version
|
||||
|
||||
# 清理并重新编译
|
||||
mvn clean compile
|
||||
```
|
||||
|
||||
### 问题2:导入时报错 "字段不存在"
|
||||
**原因:** 数据库脚本未执行
|
||||
**解决:**
|
||||
```sql
|
||||
-- 检查字段是否存在
|
||||
SHOW COLUMNS FROM blade_transport_plan LIKE 'mileage';
|
||||
SHOW COLUMNS FROM blade_transport_plan LIKE 'plan_group_id';
|
||||
|
||||
-- 如果不存在,执行迁移脚本
|
||||
SOURCE doc/sql/transport/blade_transport_plan_add_fields_20260908.sql;
|
||||
```
|
||||
|
||||
### 问题3:错误Excel未下载
|
||||
**原因:** 浏览器拦截下载
|
||||
**解决:** 检查浏览器下载设置,允许自动下载
|
||||
|
||||
### 问题4:导入后数据丢失
|
||||
**原因:** 部分数据有错导致全部回滚(这是预期行为)
|
||||
**解决:** 修正所有错误数据后重新导入
|
||||
|
||||
### 问题5:计划名称重复错误
|
||||
**原因:** 当前组织下已存在同名计划
|
||||
**解决:**
|
||||
1. 修改计划名称
|
||||
2. 或删除/修改已有的同名计划
|
||||
|
||||
## 五、回滚方案
|
||||
|
||||
如果新功能有问题,需要回滚:
|
||||
|
||||
### 回滚步骤1:恢复后端代码
|
||||
```bash
|
||||
cd /Users/liangxin/Project/JAVA/tms-erp-api
|
||||
git checkout HEAD -- blade-service/blade-transport/
|
||||
git checkout HEAD -- blade-service-api/blade-transport-api/
|
||||
```
|
||||
|
||||
### 回滚步骤2:恢复前端代码
|
||||
```bash
|
||||
cd /Users/liangxin/Project/Html/web/tms-erp-web
|
||||
git checkout HEAD -- src/views/business/transport-plan-import.vue
|
||||
```
|
||||
|
||||
### 回滚步骤3:数据库字段(可选)
|
||||
```sql
|
||||
-- 新字段不影响旧功能,可以不删除
|
||||
-- 如需删除:
|
||||
ALTER TABLE blade_transport_plan DROP COLUMN mileage;
|
||||
ALTER TABLE blade_transport_plan DROP COLUMN plan_group_id;
|
||||
```
|
||||
|
||||
### 回滚步骤4:重新编译部署
|
||||
按照正常流程重新编译和部署
|
||||
|
||||
## 六、监控指标
|
||||
|
||||
部署后需要关注:
|
||||
|
||||
1. **导入成功率**
|
||||
- 观察用户导入的成功/失败比例
|
||||
- 预期:初期可能失败率较高(用户适应新校验规则)
|
||||
|
||||
2. **常见错误类型**
|
||||
- 统计最常见的校验错误
|
||||
- 针对性优化用户指导文档
|
||||
|
||||
3. **性能监控**
|
||||
- 大批量导入(1000+条)的响应时间
|
||||
- 数据库查询性能
|
||||
|
||||
4. **用户反馈**
|
||||
- 收集用户对新校验规则的反馈
|
||||
- 调整过严或过松的规则
|
||||
|
||||
## 七、用户培训要点
|
||||
|
||||
向用户说明:
|
||||
|
||||
1. ✅ **新的校验逻辑**
|
||||
- 所有数据必须全部正确才能导入
|
||||
- 有任何错误都不会导入
|
||||
|
||||
2. ✅ **错误Excel使用**
|
||||
- 下载错误Excel查看具体错误
|
||||
- 修正后重新上传
|
||||
|
||||
3. ✅ **运输类型枚举值**
|
||||
- 必须使用:公路整车、公路配载/零担、铁路运输、水路运输、跨境海运、航空运输
|
||||
|
||||
4. ✅ **电话格式要求**
|
||||
- 必须是11位数字
|
||||
- 不要包含空格、横线等符号
|
||||
|
||||
5. ✅ **计划名称唯一性**
|
||||
- 当前组织下不能重复
|
||||
- 建议使用日期+序号命名
|
||||
|
||||
6. ✅ **同一计划标识号**
|
||||
- 用于关联同一计划的多条货物记录
|
||||
- 同一标识号下应保持地址等信息一致
|
||||
|
||||
## 八、技术支持
|
||||
|
||||
如遇到问题:
|
||||
|
||||
1. 查看服务日志
|
||||
2. 检查数据库字段是否正确
|
||||
3. 验证导入Excel格式
|
||||
4. 联系开发团队
|
||||
|
||||
---
|
||||
|
||||
**部署完成检查项:**
|
||||
- [ ] 数据库字段已添加
|
||||
- [ ] 后端服务已重启
|
||||
- [ ] 前端已更新
|
||||
- [ ] 模板下载正常
|
||||
- [ ] 基础导入测试通过
|
||||
- [ ] 错误校验测试通过
|
||||
- [ ] 用户已培训
|
||||
|
||||
**预计部署时间:** 30分钟(不含用户培训)
|
||||
**建议部署时间:** 非业务高峰期
|
||||
Reference in New Issue
Block a user