1、调整凭证

2、调整运单
This commit is contained in:
2026-09-08 17:50:34 +08:00
parent a7315b4491
commit cba2ad3b3b
43 changed files with 5730 additions and 1119 deletions
+16
View File
@@ -5,6 +5,19 @@ const baseUrl = '/blade-transport/voucher-manage';
export const getList = (current, size, params) => export const getList = (current, size, params) =>
request({ url: `${baseUrl}/list`, method: 'get', params: { 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 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 submit = data => request({ url: `${baseUrl}/submit`, method: 'post', data });
export const createUploadDraft = data => export const createUploadDraft = data =>
request({ url: `${baseUrl}/upload-draft`, method: 'post', data }); request({ url: `${baseUrl}/upload-draft`, method: 'post', data });
@@ -17,6 +30,9 @@ export const getWaybillBatches = (current, size, params) =>
method: 'get', method: 'get',
params: { current, size, ...params }, 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'; const fileTaskBaseUrl = '/blade-file/fileTask';
export const queryFileTask = md5 => 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 save = data => request({ url: `${baseUrl}/save`, method: 'post', data });
export const remove = id => request({ url: `${baseUrl}/remove`, method: 'post', params: { id } }); 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 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 => export const unmatch = internalId =>
request({ url: `${baseUrl}/unmatch`, method: 'post', params: { internalId } }); request({ url: `${baseUrl}/unmatch`, method: 'post', params: { internalId } });
export const adjust = data => request({ url: `${baseUrl}/adjust`, method: 'post', data }); 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 } }); request({ url: `${baseUrl}/update-by-match`, method: 'post', params: { id } });
export const complete = id => export const complete = id =>
request({ url: `${baseUrl}/complete`, method: 'post', params: { 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 => export const template = mode =>
request({ url: `${baseUrl}/template`, method: 'get', params: { mode }, responseType: 'blob' }); request({ url: `${baseUrl}/template`, method: 'get', params: { mode }, responseType: 'blob' });
+61 -13
View File
@@ -1,11 +1,5 @@
<template> <template>
<el-dialog <el-dialog v-model="visible" title="地图选择地址" width="920px" append-to-body @opened="initMap">
v-model="visible"
title="地图选择地址"
width="920px"
append-to-body
@opened="initMap"
>
<div class="address-map-picker__toolbar"> <div class="address-map-picker__toolbar">
<el-input <el-input
v-model="keyword" v-model="keyword"
@@ -15,7 +9,14 @@
/> />
<el-button type="primary" :loading="searching" @click="searchKeyword">搜索</el-button> <el-button type="primary" :loading="searching" @click="searchKeyword">搜索</el-button>
</div> </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 ref="map" class="address-map-picker__map"></div>
</div>
<div class="address-map-picker__info">{{ status }}</div> <div class="address-map-picker__info">{{ status }}</div>
<template #footer> <template #footer>
<el-button @click="visible = false">取消</el-button> <el-button @click="visible = false">取消</el-button>
@@ -54,6 +55,8 @@ export default {
amap: null, amap: null,
marker: null, marker: null,
geocoder: null, geocoder: null,
searchResults: [],
mapInitializing: null,
}; };
}, },
watch: { watch: {
@@ -65,6 +68,7 @@ export default {
this.keyword = this.address || ''; this.keyword = this.address || '';
this.selected = {}; this.selected = {};
this.status = '可搜索地址或点击地图选点'; this.status = '可搜索地址或点击地图选点';
this.searchResults = [];
} }
}, },
}, },
@@ -96,9 +100,16 @@ export default {
return amapLoader; return amapLoader;
}, },
initMap() { initMap() {
this.loadAmap() if (this.mapInitializing) return this.mapInitializing;
this.mapInitializing = this.loadAmap()
.then(() => { .then(() => {
if (!window.AMap?.Map) {
throw new Error('高德地图组件未就绪');
}
return new Promise(resolve => {
this.$nextTick(() => { this.$nextTick(() => {
if (!this.$refs.map) return resolve(null);
if (!this.amap) { if (!this.amap) {
this.amap = new window.AMap.Map(this.$refs.map, { this.amap = new window.AMap.Map(this.$refs.map, {
center: [116.40769, 39.89945], center: [116.40769, 39.89945],
@@ -109,12 +120,19 @@ export default {
this.amap.resize(); this.amap.resize();
this.clearMarker(); this.clearMarker();
} }
resolve(this.amap);
});
}); });
}) })
.catch(() => { .catch(() => {
this.$message.error('高德地图组件加载失败,请稍后重试'); this.$message.error('高德地图组件加载失败,请稍后重试');
this.visible = false; this.visible = false;
return null;
})
.finally(() => {
this.mapInitializing = null;
}); });
return this.mapInitializing;
}, },
ensureGeocoder() { ensureGeocoder() {
if (this.geocoder) return Promise.resolve(this.geocoder); if (this.geocoder) return Promise.resolve(this.geocoder);
@@ -154,15 +172,28 @@ export default {
this.$message.warning('请输入地址关键词'); this.$message.warning('请输入地址关键词');
return; return;
} }
if (this.searching) return;
this.searchResults = [];
this.searching = true; this.searching = true;
this.loadAmap() this.initMap()
.then(() => this.runGeocode('location', keyword)) .then(map => (map ? this.runGeocode('location', keyword) : null))
.then(result => { .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) { if (!point) {
throw new Error('未找到匹配地址'); throw new Error('未找到匹配地址');
} }
this.pickPoint(point, keyword); return this.$nextTick().then(() => {
this.amap?.resize();
this.pickPoint(point, geocodes[0]?.formattedAddress || keyword);
});
}) })
.catch(() => { .catch(() => {
this.status = '未找到匹配地址'; this.status = '未找到匹配地址';
@@ -172,6 +203,11 @@ export default {
this.searching = false; this.searching = false;
}); });
}, },
selectSearchResult(item) {
if (item && item.location) {
this.pickPoint(item.location, item.address || item.name || '');
}
},
pickPoint(point, fallbackAddress = '') { pickPoint(point, fallbackAddress = '') {
const longitude = typeof point.getLng === 'function' ? point.getLng() : point.lng; const longitude = typeof point.getLng === 'function' ? point.getLng() : point.lng;
const latitude = typeof point.getLat === 'function' ? point.getLat() : point.lat; 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)); const lnglat = new window.AMap.LngLat(Number(longitude), Number(latitude));
this.renderMarker(lnglat); 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.status = '正在解析地址...';
this.resolving = true; this.resolving = true;
this.runGeocode('address', lnglat) this.runGeocode('address', lnglat)
@@ -238,11 +278,19 @@ export default {
} }
&__map { &__map {
flex: 1 1 auto;
width: 100%; width: 100%;
min-width: 0;
height: 420px; height: 420px;
border: 1px solid #eff1f7; border: 1px solid #eff1f7;
} }
&__content {
display: flex;
gap: 12px;
min-width: 0;
}
&__info { &__info {
margin-top: 10px; margin-top: 10px;
color: #606266; 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 { baseUrl } from '@/config/env';
import { getUploadHeaders } from '@/utils/upload'; import { getUploadHeaders } from '@/utils/upload';
const UNIFIED_ATTACHMENT_TIP = const UNIFIED_ATTACHMENT_TIP_PREFIX =
'支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M'; '支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过';
const MIME_MAP = { const MIME_MAP = {
jpg: 'image/jpeg', jpg: 'image/jpeg',
@@ -215,7 +215,7 @@ export default {
return this.acceptedList.join(','); return this.acceptedList.join(',');
}, },
tipText() { tipText() {
return this.tip || UNIFIED_ATTACHMENT_TIP; return this.tip || `${UNIFIED_ATTACHMENT_TIP_PREFIX}${this.maxSize}M`;
}, },
uploadingCount() { uploadingCount() {
return this.fileList.filter(file => ['ready', 'uploading'].includes(file.status)).length; return this.fileList.filter(file => ['ready', 'uploading'].includes(file.status)).length;
+2
View File
@@ -43,6 +43,7 @@ import tenantPackage from './views/system/tenantpackage.vue';
import tenantDatasource from './views/system/tenantdatasource.vue'; import tenantDatasource from './views/system/tenantdatasource.vue';
// 通用弹窗分组白卡(灰底弹窗内使用,标题在卡内) // 通用弹窗分组白卡(灰底弹窗内使用,标题在卡内)
import sectionCard from './components/section-card/main.vue'; import sectionCard from './components/section-card/main.vue';
import mapSearchResults from './components/map-search-results/main.vue';
window.$crudCommon = crudCommon; window.$crudCommon = crudCommon;
debug(); debug();
@@ -66,6 +67,7 @@ app.component('formSetting', formSetting);
app.component('tenantPackage', tenantPackage); app.component('tenantPackage', tenantPackage);
app.component('tenantDatasource', tenantDatasource); app.component('tenantDatasource', tenantDatasource);
app.component('sectionCard', sectionCard); app.component('sectionCard', sectionCard);
app.component('mapSearchResults', mapSearchResults);
app.config.globalProperties.$app = app; app.config.globalProperties.$app = app;
app.config.globalProperties.$dayjs = dayjs; app.config.globalProperties.$dayjs = dayjs;
app.config.globalProperties.website = website; app.config.globalProperties.website = website;
+1 -1
View File
@@ -8,7 +8,7 @@ export const transportTypeDict = {
}, },
}; };
export const carrierTypeOptions = ['承运商', '自运', '网货平台']; export const carrierTypeOptions = ['承运商', '自运'];
export const loadingStatusOptions = [ export const loadingStatusOptions = [
{ label: '草稿', value: 'draft' }, { label: '草稿', value: 'draft' },
+11 -1
View File
@@ -377,6 +377,8 @@ export const config = {
title: '运单管理', title: '运单管理',
permission: 'waybill_manage', permission: 'waybill_manage',
importUrl: '/blade-transport/waybill-manage/import-waybill-manage', importUrl: '/blade-transport/waybill-manage/import-waybill-manage',
exportUrl: '/blade-transport/waybill-manage/export-waybill-manage',
exportName: '运单管理',
defaultForm: { defaultForm: {
carrierType: '承运商', carrierType: '承运商',
transportType: 'road', transportType: 'road',
@@ -444,8 +446,16 @@ export const config = {
return isDriverRejectedWaybill(row); return isDriverRejectedWaybill(row);
}, },
canEdit(row) { canEdit(row) {
// 进行中的运单不允许编辑
if (isInProgressStatus(row, row.businessStatusName)) {
return false;
}
return !isDriverRejectedWaybill(row); return !isDriverRejectedWaybill(row);
}, },
canCancel(row) {
// 进行中的运单不允许取消
return !isInProgressStatus(row, row.businessStatusName);
},
deleteStatus: ['draft'], deleteStatus: ['draft'],
editStatus: ['draft', 'pending'], editStatus: ['draft', 'pending'],
}; };
@@ -637,7 +647,7 @@ export const option = {
viewDisplay: false, viewDisplay: false,
}, },
{ {
label: '单价(计价单位)', label: '单价',
prop: 'unitPrice', prop: 'unitPrice',
formatter: row => formatUnitPrice(row), formatter: row => formatUnitPrice(row),
minWidth: 140, minWidth: 140,
@@ -18,7 +18,7 @@ export const transportReconciliationTableColumns = [
export const internalColumns = [ export const internalColumns = [
{ prop: 'documentNo', label: '单据号', minWidth: 150 }, { prop: 'documentNo', label: '单据号', minWidth: 150 },
{ prop: 'waybillNo', label: '运单号', minWidth: 120 }, { prop: 'waybillNo', label: '运单号', minWidth: 120 },
{ prop: 'matchedExternalLineNo', label: '匹配外部账单行号', minWidth: 140 }, { prop: 'matchedExternalLineNo', label: '匹配外部账单行号', minWidth: 180 },
{ prop: 'vehicleNo', label: '车号', minWidth: 100 }, { prop: 'vehicleNo', label: '车号', minWidth: 100 },
{ prop: 'departureAddress', label: '发货地址', minWidth: 180 }, { prop: 'departureAddress', label: '发货地址', minWidth: 180 },
{ prop: 'arrivalAddress', label: '到货地址', minWidth: 180 }, { prop: 'arrivalAddress', label: '到货地址', minWidth: 180 },
@@ -66,9 +66,9 @@ export const externalCargoColumns = [
{ prop: 'cargoType', label: '货物类型', minWidth: 120 }, { prop: 'cargoType', label: '货物类型', minWidth: 120 },
{ prop: 'specification', label: '规格', minWidth: 100 }, { prop: 'specification', label: '规格', minWidth: 100 },
{ prop: 'model', 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: 'unitPrice', label: '运输单价', minWidth: 110, money: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 120, number: true }, { prop: 'mileage', label: '里程(KM', minWidth: 120, number: true },
{ prop: 'freightAmount', label: '运输费', minWidth: 110, money: true }, { prop: 'freightAmount', label: '运输费', minWidth: 110, money: true },
{ prop: 'settlementAmount', label: '结算金额', minWidth: 120, money: true }, { prop: 'settlementAmount', label: '结算费用合计', minWidth: 130, money: true },
]; ];
+48
View File
@@ -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', path: '/business/contract-manage/form',
component: Layout, 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', path: '/business/waybill-import',
component: Layout, 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', path: '/business/shipping-template/form',
component: Layout, component: Layout,
+17
View File
@@ -149,3 +149,20 @@ a {
top: 0; top: 0;
bottom: 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;
}
}
}
+15
View File
@@ -232,7 +232,10 @@
搜索 搜索
</el-button> </el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results :results="mapSearchResults" @select="selectMapSearchResult" />
<div ref="amap" class="common-address-page__map"></div> <div ref="amap" class="common-address-page__map"></div>
</div>
<div class="common-address-page__map-info"> <div class="common-address-page__map-info">
<span>{{ mapStatus }}</span> <span>{{ mapStatus }}</span>
<span v-if="mapSelected.regionName">行政区划{{ mapSelected.regionName }}</span> <span v-if="mapSelected.regionName">行政区划{{ mapSelected.regionName }}</span>
@@ -298,6 +301,7 @@ export default {
mapKeyword: '', mapKeyword: '',
mapStatus: '可搜索地址或点击地图选点', mapStatus: '可搜索地址或点击地图选点',
mapSelected: {}, mapSelected: {},
mapSearchResults: [],
amap: null, amap: null,
amapMarker: null, amapMarker: null,
amapGeocoder: null, amapGeocoder: null,
@@ -1113,6 +1117,12 @@ export default {
this.ensureAmapGeocoder() this.ensureAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword)) .then(() => this.runAmapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveMapPoint(result);
if (!point) { if (!point) {
this.mapStatus = '未找到匹配地址'; this.mapStatus = '未找到匹配地址';
@@ -1133,6 +1143,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗'); this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
}); });
}, },
selectMapSearchResult(item) {
if (item && item.location) {
this.pickMapPoint(item.location, item.address || item.name || '');
}
},
pickMapPoint(lnglat, keyword) { pickMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat); const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat); const latitude = this.getPointLat(lnglat);
+15
View File
@@ -288,7 +288,10 @@
搜索 搜索
</el-button> </el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results :results="routeMapSearchResults" @select="selectRouteMapSearchResult" />
<div ref="routeAmap" class="common-route-page__map"></div> <div ref="routeAmap" class="common-route-page__map"></div>
</div>
<div class="common-route-page__map-info"> <div class="common-route-page__map-info">
<span>{{ routeMapStatus }}</span> <span>{{ routeMapStatus }}</span>
<span v-if="routeMapSelected.regionName" <span v-if="routeMapSelected.regionName"
@@ -359,6 +362,7 @@ export default {
routeMapKeyword: '', routeMapKeyword: '',
routeMapStatus: '可搜索地址或点击地图选点', routeMapStatus: '可搜索地址或点击地图选点',
routeMapSelected: {}, routeMapSelected: {},
routeMapSearchResults: [],
routeAmap: null, routeAmap: null,
routeAmapMarker: null, routeAmapMarker: null,
routeAmapGeocoder: null, routeAmapGeocoder: null,
@@ -612,6 +616,12 @@ export default {
}) })
.then(() => this.runAmapGeocode('location', keyword)) .then(() => this.runAmapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveMapPoint(result);
if (!point) { if (!point) {
this.routeMapStatus = '未找到匹配地址'; this.routeMapStatus = '未找到匹配地址';
@@ -628,6 +638,11 @@ export default {
this.routeMapLoading = false; this.routeMapLoading = false;
}); });
}, },
selectRouteMapSearchResult(item) {
if (item && item.location) {
this.pickRouteMapPoint(item.location, item.address || item.name || '');
}
},
pickRouteMapPoint(lnglat, keyword) { pickRouteMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat); const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat); const latitude = this.getPointLat(lnglat);
@@ -2047,7 +2047,7 @@
v-model="attachmentRows" v-model="attachmentRows"
:readonly="dialogReadonly" :readonly="dialogReadonly"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleAttachmentChange" @change="handleAttachmentChange"
@@ -4265,7 +4265,7 @@
<vehicle-attachment-upload <vehicle-attachment-upload
v-model="dispatchItemAttachmentRows" v-model="dispatchItemAttachmentRows"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleDispatchItemAttachmentChange" @change="handleDispatchItemAttachmentChange"
@@ -4965,7 +4965,10 @@
搜索 搜索
</el-button> </el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
<div ref="transportAmap" class="business-crud-page__map"></div> <div ref="transportAmap" class="business-crud-page__map"></div>
</div>
<div class="business-crud-page__map-info"> <div class="business-crud-page__map-info">
<span>{{ transportMapStatus }}</span> <span>{{ transportMapStatus }}</span>
<span v-if="transportMapSelected.regionName" <span v-if="transportMapSelected.regionName"
@@ -5500,7 +5503,7 @@ const defaultDispatchRow = () => ({
remark: '', remark: '',
}); });
const taskCarrierTypes = ['承运商', '自运', '网货平台']; const taskCarrierTypes = ['承运商', '自运'];
export default { export default {
components: { components: {
@@ -5735,6 +5738,7 @@ export default {
transportMapKeyword: '', transportMapKeyword: '',
transportMapStatus: '可搜索地址或点击地图选点', transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {}, transportMapSelected: {},
transportMapSearchResults: [],
transportAmap: null, transportAmap: null,
transportAmapMarker: null, transportAmapMarker: null,
transportAmapGeocoder: null, transportAmapGeocoder: null,
@@ -6094,7 +6098,7 @@ export default {
return this.taskCarrierTypes; return this.taskCarrierTypes;
}, },
taskCarrierLabel() { taskCarrierLabel() {
return this.form.carrierType === '网货平台' ? '承运方' : '承运商'; return '承运商';
}, },
taskCarrierValue: { taskCarrierValue: {
get() { get() {
@@ -6258,7 +6262,7 @@ export default {
return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType); return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType);
}, },
dispatchCarrierLabel() { dispatchCarrierLabel() {
return this.dispatchItemForm.carrierType === '网货平台' ? '承运方' : '承运商'; return '承运商';
}, },
dispatchCarrierSelection: { dispatchCarrierSelection: {
get() { get() {
@@ -7155,7 +7159,7 @@ export default {
this.$message.info('当前运单暂无详情数据'); this.$message.info('当前运单暂无详情数据');
return; 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) { transportPlanWaybillIndex(index) {
return ( return (
@@ -8793,7 +8797,7 @@ export default {
} }
}, },
isTaskCarrierRequired(carrierType) { isTaskCarrierRequired(carrierType) {
return ['承运商', '网货平台'].includes(String(carrierType || '').trim()); return String(carrierType || '').trim() === '承运商';
}, },
resetWaybillCarrierContractState() { resetWaybillCarrierContractState() {
if (!this.isWaybillDetailLayout) return; if (!this.isWaybillDetailLayout) return;
@@ -9267,7 +9271,7 @@ export default {
['vehicleNo', this.transportVehicleNoLabel], ['vehicleNo', this.transportVehicleNoLabel],
]; ];
if (this.isTaskCarrierRequired(row.carrierType)) { if (this.isTaskCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']); requiredFields.push(['carrierName', '承运商']);
} }
if (this.isWaybillDetailLayout && row.carrierType === '承运商') { if (this.isWaybillDetailLayout && row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']); requiredFields.push(['carrierContractId', '承运商合同']);
@@ -10106,6 +10110,12 @@ export default {
this.ensureTransportAmapGeocoder() this.ensureTransportAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword)) .then(() => this.runAmapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveMapPoint(result);
if (!point) { if (!point) {
this.transportMapStatus = '未找到匹配地址'; this.transportMapStatus = '未找到匹配地址';
@@ -10126,6 +10136,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗'); this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
}); });
}, },
selectTransportMapSearchResult(item) {
if (item && item.location) {
this.pickTransportMapPoint(item.location, item.address || item.name || '');
}
},
pickTransportMapPoint(lnglat, keyword) { pickTransportMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat); const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat); const latitude = this.getPointLat(lnglat);
@@ -12671,7 +12686,7 @@ export default {
this.loadDispatchCarrierOptions(this.dispatchRow); this.loadDispatchCarrierOptions(this.dispatchRow);
}, },
isDispatchCarrierRequired(carrierType) { isDispatchCarrierRequired(carrierType) {
return ['承运商', '网货平台'].includes(String(carrierType || '').trim()); return String(carrierType || '').trim() === '承运商';
}, },
getProjectCarrierOptions(project = {}) { getProjectCarrierOptions(project = {}) {
const rows = this.parseJsonArray(project.carrierJson); const rows = this.parseJsonArray(project.carrierJson);
@@ -13058,7 +13073,7 @@ export default {
], ],
]; ];
if (this.isDispatchCarrierRequired(row.carrierType)) { if (this.isDispatchCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']); requiredFields.push(['carrierName', '承运商']);
} }
if (row.carrierType === '承运商') { if (row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']); requiredFields.push(['carrierContractId', '承运商合同']);
@@ -144,7 +144,9 @@ export default {
: [row.captainName, vehicle]; : [row.captainName, vehicle];
return values.filter(Boolean).join(' / ') || '-'; 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 } }); }, openPlan(row) { this.$router.push({ path: '/business/transport-plan', query: { detailId: row.id } }); },
waybillStatusName(value) { return ({ pending: '待执行', waiting: '待执行', running: '进行中', processing: '进行中', completed: '已完成', cancelled: '已取消' })[value] || value || '-'; }, 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'; }, 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 === '运单'"> <template v-if="route.documentType === '运单'">
<div class="goods-heading"><h3>任务信息</h3></div> <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-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-form">
<el-row :gutter="16"> <el-row :gutter="16">
<template v-if="isRoad(route)"> <template v-if="isRoad(route)">
@@ -862,7 +862,7 @@ export default {
if (route.documentType === '运单') { if (route.documentType === '运单') {
if (this.isRoad(route)) { if (this.isRoad(route)) {
if (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName || !route.vehicleNo)) return this.$message.warning('请选择承运商并填写车牌号'); 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))) { } else if (!route.vehicleNo || (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName))) {
return this.$message.warning('请补全非公路运输的承运信息'); return this.$message.warning('请补全非公路运输的承运信息');
} }
@@ -132,7 +132,7 @@
<div class="goods-heading"> <div class="goods-heading">
<h3>货物信息</h3> <h3>货物信息</h3>
<div class="goods-toolbar"> <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> <el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div> </div>
</div> </div>
@@ -283,7 +283,7 @@
<vehicle-attachment-upload <vehicle-attachment-upload
v-model="attachmentRows" v-model="attachmentRows"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleAttachmentChange" @change="handleAttachmentChange"
@@ -401,7 +401,10 @@
>搜索</el-button >搜索</el-button
> >
</div> </div>
<div class="map-picker-content">
<map-search-results :results="mapSearchResults" @select="selectAddressMapSearchResult" />
<div ref="addressMap" class="address-map" /> <div ref="addressMap" class="address-map" />
</div>
<div class="address-map-status"> <div class="address-map-status">
{{ mapStatus {{ mapStatus
}}<span v-if="mapSelected.regionName"> 行政区划:{{ mapSelected.regionName }}</span> }}<span v-if="mapSelected.regionName"> 行政区划:{{ mapSelected.regionName }}</span>
@@ -639,6 +642,7 @@ export default {
mapKeyword: '', mapKeyword: '',
mapStatus: '可搜索地址或点击地图选点', mapStatus: '可搜索地址或点击地图选点',
mapSelected: {}, mapSelected: {},
mapSearchResults: [],
addressMapInstance: null, addressMapInstance: null,
addressMapMarker: null, addressMapMarker: null,
addressMapGeocoder: null, addressMapGeocoder: null,
@@ -1281,12 +1285,24 @@ export default {
) )
) )
.then(result => { .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('地图搜索无匹配地址'); if (!point) return this.$message.warning('地图搜索无匹配地址');
this.pickAddressMap(point, this.mapKeyword); this.pickAddressMap(point, this.mapKeyword);
}) })
.catch(() => this.$message.error('地图搜索失败')); .catch(() => this.$message.error('地图搜索失败'));
}, },
selectAddressMapSearchResult(item) {
if (item && item.location) {
this.pickAddressMap(item.location, item.address || item.name || '');
}
},
ensureAddressGeocoder() { ensureAddressGeocoder() {
if (this.addressMapGeocoder) return Promise.resolve(this.addressMapGeocoder); if (this.addressMapGeocoder) return Promise.resolve(this.addressMapGeocoder);
return new Promise(resolve => return new Promise(resolve =>
@@ -223,7 +223,7 @@
> >
<span>货物信息</span> <span>货物信息</span>
<div v-if="!dialogReadonly" class="shipping-template-page__section-actions"> <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> <el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div> </div>
</div> </div>
@@ -233,7 +233,10 @@
<div class="shipping-template-page__cargo-wrap"> <div class="shipping-template-page__cargo-wrap">
<el-table :data="transportCargoRows" border empty-text="暂无货物信息"> <el-table :data="transportCargoRows" border empty-text="暂无货物信息">
<el-table-column type="index" label="序号" width="70" align="center" /> <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 }"> <template #default="{ row }">
<el-cascader <el-cascader
v-model="row.cargoTypePath" v-model="row.cargoTypePath"
@@ -482,7 +485,7 @@
v-model="attachmentRows" v-model="attachmentRows"
:readonly="dialogReadonly" :readonly="dialogReadonly"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleAttachmentChange" @change="handleAttachmentChange"
@@ -925,7 +928,13 @@
@keyup.enter="searchTransportMapKeyword" @keyup.enter="searchTransportMapKeyword"
/><el-button type="primary" @click="searchTransportMapKeyword">搜索</el-button> /><el-button type="primary" @click="searchTransportMapKeyword">搜索</el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results
:results="transportMapSearchResults"
@select="selectTransportMapSearchResult"
/>
<div ref="transportMap" class="shipping-template-page__map" /> <div ref="transportMap" class="shipping-template-page__map" />
</div>
<div class="shipping-template-page__map-info">{{ transportMapStatus }}</div> <div class="shipping-template-page__map-info">{{ transportMapStatus }}</div>
<template #footer <template #footer
><el-button @click="transportMapBox = false">取消</el-button ><el-button @click="transportMapBox = false">取消</el-button
@@ -946,12 +955,45 @@
width="1200px" width="1200px"
@opened="loadCommonCargoList" @opened="loadCommonCargoList"
> >
<el-form :model="commonCargoQuery" inline <div class="shipping-template-page__dialog-search">
><el-form-item label="货物名称" <el-form :model="commonCargoQuery" label-position="right" label-width="100px">
><el-input v-model="commonCargoQuery.cargoName" clearable /></el-form-item <el-row :gutter="16">
><el-button type="primary" @click="handleCommonCargoSearch">查询</el-button <el-col :span="6">
><el-button @click="handleCommonCargoReset">重置</el-button></el-form <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 <el-table
:data="commonCargoRows" :data="commonCargoRows"
border border
@@ -1001,35 +1043,6 @@
> >
</el-dialog> </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 <el-dialog
v-model="shippingTemplateProcessConfigBox" v-model="shippingTemplateProcessConfigBox"
title="过程配置" title="过程配置"
@@ -1066,7 +1079,7 @@
</template> </template>
<script> <script>
import { exportBlob, importBlob } from '@/api/common'; import { exportBlob } from '@/api/common';
import { getList as getCommonAddressList } from '@/api/base/common-address'; import { getList as getCommonAddressList } from '@/api/base/common-address';
import { getList as getAirportMasterList } from '@/api/base/airport-master'; import { getList as getAirportMasterList } from '@/api/base/airport-master';
import { getList as getCargoTypeList } from '@/api/base/cargo-type'; import { getList as getCargoTypeList } from '@/api/base/cargo-type';
@@ -1320,6 +1333,7 @@ export default {
transportMapLoading: false, transportMapLoading: false,
transportMapStatus: '可搜索地址或点击地图选点', transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {}, transportMapSelected: {},
transportMapSearchResults: [],
transportMapInstance: null, transportMapInstance: null,
transportMapMarker: null, transportMapMarker: null,
transportMapGeocoder: null, transportMapGeocoder: null,
@@ -1329,9 +1343,6 @@ export default {
commonCargoRows: [], commonCargoRows: [],
commonCargoSelected: [], commonCargoSelected: [],
commonCargoPage: { pageSize: 10, currentPage: 1, total: 0 }, commonCargoPage: { pageSize: 10, currentPage: 1, total: 0 },
cargoImportBox: false,
cargoImportLoading: false,
cargoImportRows: [],
shippingTemplateProcessConfigBox: false, shippingTemplateProcessConfigBox: false,
shippingTemplateProcessConfigLoading: false, shippingTemplateProcessConfigLoading: false,
shippingTemplateProcessConfigRows: [], shippingTemplateProcessConfigRows: [],
@@ -1780,6 +1791,9 @@ export default {
this.normalizeCargo(item) this.normalizeCargo(item)
); );
if (!this.transportCargoRows.length) this.transportCargoRows = [defaultCargo()]; if (!this.transportCargoRows.length) this.transportCargoRows = [defaultCargo()];
this.restoreCargoTypePaths();
// 编辑时先加载货物类型树,保证已有货物类型可以回显为级联路径。
this.ensureCargoTypeOptions().catch(() => {});
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson); this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.shippingTemplateFreight = { this.shippingTemplateFreight = {
...defaultFreight(), ...defaultFreight(),
@@ -1846,7 +1860,11 @@ export default {
return false; return false;
} }
for (const [index, row] of this.transportCargoRows.entries()) { 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}行货物信息不完整`); this.$message.warning(`第${index + 1}行货物信息不完整`);
return false; return false;
} }
@@ -2339,6 +2357,7 @@ export default {
regionName: this.form[`${prefix}Name`], regionName: this.form[`${prefix}Name`],
addressCode: this.form[`${prefix}AddressCode`], addressCode: this.form[`${prefix}AddressCode`],
}); });
this.transportMapSearchResults = [];
this.transportMapStatus = this.transportMapSelected.longitude this.transportMapStatus = this.transportMapSelected.longitude
? '已加载当前选点,可重新选点' ? '已加载当前选点,可重新选点'
: '可搜索地址或点击地图选点'; : '可搜索地址或点击地图选点';
@@ -2403,9 +2422,18 @@ export default {
.then(() => this.ensureTransportMapGeocoder()) .then(() => this.ensureTransportMapGeocoder())
.then(() => this.runTransportMapGeocode('location', keyword)) .then(() => this.runTransportMapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveTransportMapPoint(result);
if (!point) throw new Error('未找到匹配地址'); if (!point) throw new Error('未找到匹配地址');
return this.$nextTick().then(() => {
this.transportMapInstance?.resize();
return this.pickMapPoint(point, keyword); return this.pickMapPoint(point, keyword);
});
}) })
.catch(() => { .catch(() => {
this.transportMapStatus = '地图搜索失败'; this.transportMapStatus = '地图搜索失败';
@@ -2415,6 +2443,11 @@ export default {
this.transportMapLoading = false; this.transportMapLoading = false;
}); });
}, },
selectTransportMapSearchResult(item) {
if (item && item.location) {
this.pickMapPoint(item.location, item.address || item.name || '');
}
},
pickMapPoint(point, keyword = '') { pickMapPoint(point, keyword = '') {
const longitude = this.getTransportMapPointLng(point); const longitude = this.getTransportMapPointLng(point);
const latitude = this.getTransportMapPointLat(point); const latitude = this.getTransportMapPointLat(point);
@@ -2550,6 +2583,7 @@ export default {
.then(res => { .then(res => {
this.cargoTypeOptions = this.buildCargoTree(extractRecords(res)); this.cargoTypeOptions = this.buildCargoTree(extractRecords(res));
this.cargoTypeFlatOptions = this.flattenCargo(this.cargoTypeOptions); this.cargoTypeFlatOptions = this.flattenCargo(this.cargoTypeOptions);
this.restoreCargoTypePaths();
return this.cargoTypeOptions; return this.cargoTypeOptions;
}) })
.finally(() => { .finally(() => {
@@ -2603,13 +2637,81 @@ export default {
cargoPath(path) { cargoPath(path) {
return Array.isArray(path) ? path.slice(0, 2).map(String) : []; 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 = {}) { normalizeCargo(row = {}) {
const cargoTypePath = this.resolveCargoTypePath(row);
const cargoType = this.findCargoTypeOption({ ...row, cargoTypePath });
return { return {
...defaultCargo(), ...defaultCargo(),
...row, ...row,
cargoTypePath: this.cargoPath(row.cargoTypePath), cargoTypePath,
cargoType: row.cargoType || row.secondCargoTypeName || row.firstCargoTypeName || '', cargoType:
cargoTypeCode: row.cargoTypeCode || row.secondCargoTypeCode || row.firstCargoTypeCode || '', cargoType?.cargoName ||
row.cargoType ||
row.secondCargoTypeName ||
row.firstCargoTypeName ||
'',
cargoTypeCode:
cargoType?.cargoCode ||
row.cargoTypeCode ||
row.secondCargoTypeCode ||
row.firstCargoTypeCode ||
'',
cargoName: row.cargoName || row.goodsName || '', cargoName: row.cargoName || row.goodsName || '',
}; };
}, },
@@ -2623,7 +2725,7 @@ export default {
.filter(Boolean); .filter(Boolean);
row.cargoTypePath = path; row.cargoTypePath = path;
row.cargoType = labels.at(-1) || ''; row.cargoType = labels.at(-1) || '';
row.cargoTypeCode = item?.cargoCode || ''; row.cargoTypeCode = item?.cargoCode || item?.code || item?.id || '';
row.cargoName = ''; row.cargoName = '';
}, },
fetchCargoSuggestions(query, callback, row) { fetchCargoSuggestions(query, callback, row) {
@@ -2637,8 +2739,28 @@ export default {
) )
.catch(() => callback([])); .catch(() => callback([]));
}, },
selectCargoSuggestion(row, item) { async selectCargoSuggestion(row, item = {}) {
Object.assign(row, this.normalizeCargo(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; row.value = undefined;
this.syncFreightItems(); this.syncFreightItems();
}, },
@@ -2786,13 +2908,16 @@ export default {
}, },
openCommonCargoDialog() { openCommonCargoDialog() {
this.commonCargoSelected = []; this.commonCargoSelected = [];
this.commonCargoQuery = {};
this.commonCargoPage.currentPage = 1; this.commonCargoPage.currentPage = 1;
this.commonCargoBox = true; this.commonCargoBox = true;
this.ensureCargoTypeOptions().catch(() => {});
}, },
loadCommonCargoList() { loadCommonCargoList() {
this.commonCargoLoading = true; this.commonCargoLoading = true;
const { cargoTypePath, ...query } = this.commonCargoQuery || {};
getCommonCargoList(this.commonCargoPage.currentPage, this.commonCargoPage.pageSize, { getCommonCargoList(this.commonCargoPage.currentPage, this.commonCargoPage.pageSize, {
...this.normalizeSearch(this.commonCargoQuery), ...this.normalizeSearch(query),
allDept: 0, allDept: 0,
}) })
.then(res => { .then(res => {
@@ -2812,6 +2937,29 @@ export default {
this.commonCargoQuery = {}; this.commonCargoQuery = {};
this.handleCommonCargoSearch(); 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) { handleCommonCargoCurrentChange(page) {
this.commonCargoPage.currentPage = page; this.commonCargoPage.currentPage = page;
this.loadCommonCargoList(); this.loadCommonCargoList();
@@ -2823,54 +2971,16 @@ export default {
handleCommonCargoSelectionChange(rows) { handleCommonCargoSelectionChange(rows) {
this.commonCargoSelected = rows || []; this.commonCargoSelected = rows || [];
}, },
selectCommonCargoRow(row) { async selectCommonCargoRow(row) {
await this.ensureCargoTypeOptions().catch(() => {});
this.addCargoRow(-1, row); this.addCargoRow(-1, row);
this.commonCargoBox = false; this.commonCargoBox = false;
}, },
confirmCommonCargoSelection() { async confirmCommonCargoSelection() {
await this.ensureCargoTypeOptions().catch(() => {});
this.commonCargoSelected.forEach(row => this.addCargoRow(-1, row)); this.commonCargoSelected.forEach(row => this.addCargoRow(-1, row));
this.commonCargoBox = false; 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) { handleAttachmentChange(list) {
const name = this.userInfo?.realName || this.userInfo?.userName || ''; const name = this.userInfo?.realName || this.userInfo?.userName || '';
const time = this.$dayjs().format('YYYY-MM-DD HH:mm:ss'); const time = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
@@ -3044,6 +3154,10 @@ export default {
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
} }
.goods-required-mark {
margin-left: 2px;
color: #f56c6c;
}
.shipping-template-page__cargo-total { .shipping-template-page__cargo-total {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
@@ -3388,10 +3502,27 @@ export default {
display: flex; display: flex;
gap: 8px; 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 { .shipping-template-page__map {
flex: 1 1 auto;
width: 100%;
min-width: 0;
height: 480px; height: 480px;
margin-top: 12px; margin-top: 12px;
} }
:deep(.shipping-template-page__map > .amap-container) {
width: 100% !important;
height: 100% !important;
}
.shipping-template-page__map-info { .shipping-template-page__map-info {
margin-top: 8px; margin-top: 8px;
color: #606266; color: #606266;
@@ -301,7 +301,7 @@
> >
<span>货物信息</span> <span>货物信息</span>
<div v-if="!dialogReadonly" class="transport-plan-page__section-actions"> <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> <el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div> </div>
</div> </div>
@@ -526,7 +526,7 @@
v-model="attachmentRows" v-model="attachmentRows"
:readonly="dialogReadonly" :readonly="dialogReadonly"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleAttachmentChange" @change="handleAttachmentChange"
@@ -681,10 +681,8 @@
<div class="transport-plan-page__transport-plan-detail-route-point"> <div class="transport-plan-page__transport-plan-detail-route-point">
<b class="is-start"></b> <b class="is-start"></b>
<div> <div>
<strong>{{ <strong>{{ formatTransportPlanDetailRouteName('departure') }}</strong>
detailRow.departureName || detailRow.departureAddress || '-' <p>{{ detailRow.departureAddress || '-' }}</p>
}}</strong>
<p>{{ formatTransportPlanProvinceCityDistrict(detailRow.departureAddress) }}</p>
<p> <p>
{{ {{
[detailRow.departureContact, detailRow.departurePhone] [detailRow.departureContact, detailRow.departurePhone]
@@ -698,8 +696,8 @@
<div class="transport-plan-page__transport-plan-detail-route-point"> <div class="transport-plan-page__transport-plan-detail-route-point">
<b class="is-end"></b> <b class="is-end"></b>
<div> <div>
<strong>{{ detailRow.arrivalName || detailRow.arrivalAddress || '-' }}</strong> <strong>{{ formatTransportPlanDetailRouteName('arrival') }}</strong>
<p>{{ formatTransportPlanProvinceCityDistrict(detailRow.arrivalAddress) }}</p> <p>{{ detailRow.arrivalAddress || '-' }}</p>
<p> <p>
{{ {{
[detailRow.arrivalContact, detailRow.arrivalPhone] [detailRow.arrivalContact, detailRow.arrivalPhone]
@@ -1558,7 +1556,7 @@
> >
<span>货物信息</span> <span>货物信息</span>
<div class="transport-plan-page__section-actions"> <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> <el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div> </div>
</div> </div>
@@ -2201,7 +2199,7 @@
<vehicle-attachment-upload <vehicle-attachment-upload
v-model="dispatchItemAttachmentRows" v-model="dispatchItemAttachmentRows"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleDispatchItemAttachmentChange" @change="handleDispatchItemAttachmentChange"
@@ -2518,7 +2516,10 @@
搜索 搜索
</el-button> </el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
<div ref="transportAmap" class="transport-plan-page__map"></div> <div ref="transportAmap" class="transport-plan-page__map"></div>
</div>
<div class="transport-plan-page__map-info"> <div class="transport-plan-page__map-info">
<span>{{ transportMapStatus }}</span> <span>{{ transportMapStatus }}</span>
<span v-if="transportMapSelected.regionName" <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 getPortTerminalList } from '@/api/base/port-terminal';
import { getList as getRailwayStationList } from '@/api/base/railway-station'; import { getList as getRailwayStationList } from '@/api/base/railway-station';
import { getList as getCommonCargoList } from '@/api/business/common-cargo'; 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 { import {
getDetail as getProjectDetail, getDetail as getProjectDetail,
getList as getProjectList, getList as getProjectList,
@@ -2945,7 +2949,7 @@ const defaultDispatchRow = () => ({
remark: '', remark: '',
}); });
const taskCarrierTypes = ['承运商', '自运', '网货平台']; const taskCarrierTypes = ['承运商', '自运'];
export default { export default {
components: { components: {
@@ -3121,6 +3125,7 @@ export default {
transportMapKeyword: '', transportMapKeyword: '',
transportMapStatus: '可搜索地址或点击地图选点', transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {}, transportMapSelected: {},
transportMapSearchResults: [],
transportAmap: null, transportAmap: null,
transportAmapMarker: null, transportAmapMarker: null,
transportAmapGeocoder: null, transportAmapGeocoder: null,
@@ -3446,7 +3451,7 @@ export default {
return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType); return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType);
}, },
dispatchCarrierLabel() { dispatchCarrierLabel() {
return this.dispatchItemForm.carrierType === '网货平台' ? '承运方' : '承运商'; return '承运商';
}, },
dispatchCarrierSelection: { dispatchCarrierSelection: {
get() { get() {
@@ -3790,6 +3795,34 @@ export default {
const cityMatch = text.match(/市/); const cityMatch = text.match(/市/);
return cityMatch ? text.slice(0, cityMatch.index + 1) : text; 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) { hasPermission(code) {
return this.isAdmin || this.validData(this.permission && this.permission[code], false); return this.isAdmin || this.validData(this.permission && this.permission[code], false);
}, },
@@ -3988,7 +4021,7 @@ export default {
this.$message.info('当前运单暂无详情数据'); this.$message.info('当前运单暂无详情数据');
return; 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) { transportPlanWaybillIndex(index) {
return ( return (
@@ -4054,10 +4087,28 @@ export default {
request request
.then(res => { .then(res => {
const detail = res?.data?.data || res?.data || row; const detail = res?.data?.data || res?.data || row;
if (!detail.contractId) {
this.detailRow = detail; this.detailRow = detail;
this.ensureTransportPlanDetailTransportTypeName(detail); this.ensureTransportPlanDetailTransportTypeName(detail);
this.applyFormDetail(detail); this.applyFormDetail(detail);
return null; 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(() => { .finally(() => {
this.detailLoading = false; this.detailLoading = false;
@@ -4610,7 +4661,11 @@ export default {
this.form.settlementCurrency = this.form.settlementCurrency =
contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || ''; contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || '';
this.form.customerName = this.form.customerName =
contract.customerName || contract.customerNames || this.form.customerName || ''; contract.partyA ||
contract.customerName ||
contract.customerNames ||
this.form.customerName ||
'';
this.$refs.crud?.validateField?.('contractName'); this.$refs.crud?.validateField?.('contractName');
}, },
loadDispatchContractCurrency(plan = {}) { loadDispatchContractCurrency(plan = {}) {
@@ -5505,6 +5560,12 @@ export default {
this.ensureTransportAmapGeocoder() this.ensureTransportAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword)) .then(() => this.runAmapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveMapPoint(result);
if (!point) { if (!point) {
this.transportMapStatus = '未找到匹配地址'; this.transportMapStatus = '未找到匹配地址';
@@ -5525,6 +5586,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗'); this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
}); });
}, },
selectTransportMapSearchResult(item) {
if (item && item.location) {
this.pickTransportMapPoint(item.location, item.address || item.name || '');
}
},
pickTransportMapPoint(lnglat, keyword) { pickTransportMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat); const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat); const latitude = this.getPointLat(lnglat);
@@ -6246,8 +6312,7 @@ export default {
}, },
handleImport() { handleImport() {
if (this.config.enableTransportPlanImport) { if (this.config.enableTransportPlanImport) {
this.transportPlanImportBox = true; this.$router.push('/business/transport-plan/import');
this.loadProjectOptions();
return; return;
} }
this.$message.warning('运输计划导入接口未配置'); this.$message.warning('运输计划导入接口未配置');
@@ -6637,32 +6702,12 @@ export default {
this.$message.warning('运输计划数据异常,无法调度'); this.$message.warning('运输计划数据异常,无法调度');
return; return;
} }
this.dispatchRow = { ...row }; //
this.dispatchCarrierOptions = []; this.$router.push({
this.dispatchCarrierContractOptions = []; path: '/business/transport-plan-dispatch',
this.dispatchCarrierRequestId += 1; query: {
this.dispatchBox = true; planId: row.id,
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;
}); });
}, },
loadDispatchTransportTypeOptions() { loadDispatchTransportTypeOptions() {
@@ -7308,7 +7353,7 @@ export default {
this.loadDispatchCarrierOptions(this.dispatchRow); this.loadDispatchCarrierOptions(this.dispatchRow);
}, },
isDispatchCarrierRequired(carrierType) { isDispatchCarrierRequired(carrierType) {
return ['承运商', '网货平台'].includes(String(carrierType || '').trim()); return String(carrierType || '').trim() === '承运商';
}, },
getProjectCarrierOptions(project = {}) { getProjectCarrierOptions(project = {}) {
const rows = this.parseJsonArray(project.carrierJson); const rows = this.parseJsonArray(project.carrierJson);
@@ -7748,7 +7793,7 @@ export default {
], ],
]; ];
if (this.isDispatchCarrierRequired(row.carrierType)) { if (this.isDispatchCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']); requiredFields.push(['carrierName', '承运商']);
} }
if (row.carrierType === '承运商') { if (row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']); requiredFields.push(['carrierContractId', '承运商合同']);
@@ -119,18 +119,22 @@
label="运单批次号" label="运单批次号"
min-width="140" min-width="140"
/><el-table-column prop="originalNo" label="原始单号" min-width="130" /><el-table-column /><el-table-column prop="originalNo" label="原始单号" min-width="130" /><el-table-column
prop="loadingIdentifier"
label="配载标识号"
min-width="130"
/><el-table-column
prop="vehicleNo" prop="vehicleNo"
label="车牌号/航班号/船号/班列号" label="车牌号/航班号/船号/班列号"
min-width="190" min-width="190"
/><el-table-column prop="driverName" label="司机/船长" min-width="120" /><el-table-column /><el-table-column prop="transportType" label="运输方式" width="110" /><el-table-column
prop="transportType" prop="driverName"
label="运输方式" label="司机/船长姓名"
width="110" min-width="120"
/><el-table-column prop="cargoName" label="货物名称" width="130" /><el-table-column /><el-table-column
prop="cargoType" prop="driverPhone"
label="货物类型" label="司机/船长手机号"
width="120" width="140"
/><el-table-column prop="quantity" label="重量" width="90" /><el-table-column /><el-table-column
prop="departureAddress" prop="departureAddress"
label="发货地址" label="发货地址"
min-width="220" min-width="220"
@@ -148,11 +152,22 @@
prop="arrivalPhone" prop="arrivalPhone"
label="收货联系人电话" label="收货联系人电话"
width="140" width="140"
/><el-table-column prop="startDate" label="开始时间" width="160" sortable /><el-table-column /><el-table-column prop="cargoName" label="货物名称" width="130" /><el-table-column
prop="endDate" prop="cargoType"
label="结束时间" label="货物类型"
width="160" width="120"
sortable /><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 /><el-table-column prop="unitPrice" label="单价" width="90" /><el-table-column
prop="freight" prop="freight"
label="运费" label="运费"
@@ -161,11 +176,31 @@
prop="freightTotal" prop="freightTotal"
label="运费合计" label="运费合计"
width="100" 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 /><el-table-column prop="remark" label="备注" min-width="160" /><el-table-column
label="操作" prop="waybillIdentifier"
width="80" label="同一运单标识号"
>-</el-table-column min-width="140"
></el-table /><el-table-column label="操作" width="80">-</el-table-column></el-table
> >
<el-pagination <el-pagination
v-model:current-page="detailPage.current" v-model:current-page="detailPage.current"
@@ -441,7 +476,6 @@
</section> </section>
<div v-if="createPage" class="waybill-import-create__footer"> <div v-if="createPage" class="waybill-import-create__footer">
<el-button @click="closeCreate">取消</el-button> <el-button @click="closeCreate">取消</el-button>
<el-button @click="saveDraft">保存草稿</el-button>
<el-button type="primary" @click="confirmImport">确认导入</el-button> <el-button type="primary" @click="confirmImport">确认导入</el-button>
</div> </div>
</component> </component>
@@ -496,7 +530,7 @@ const createDefaultForm = () => ({
const form = reactive(createDefaultForm()); const form = reactive(createDefaultForm());
// 批量导入状态仅保留草稿与导入完成两种,与后端 importStatus 取值一致。 // 批量导入状态仅保留草稿与导入完成两种,与后端 importStatus 取值一致。
const importStatusOptions = [ const importStatusOptions = [
{ label: '草稿', value: 'draft' }, { label: '进行中', value: 'draft' },
{ label: '导入完成', value: 'completed' }, { label: '导入完成', value: 'completed' },
]; ];
// 编辑草稿时明细已入库,此时不再强制重新上传附件。 // 编辑草稿时明细已入库,此时不再强制重新上传附件。
@@ -541,7 +575,7 @@ const previewRows = computed(() =>
); );
const carrierContractsLoaded = ref(false), const carrierContractsLoaded = ref(false),
hasCarrierContracts = ref(null); hasCarrierContracts = ref(null);
const carrierTypes = ['承运商', '自运', '网货平台']; const carrierTypes = ['承运商', '自运'];
const isSelfOperated = computed(() => form.carrierType === '自运'); const isSelfOperated = computed(() => form.carrierType === '自运');
// 项目下没有承运商合同时只允许自运,与运单管理的承运类型收窄规则一致。 // 项目下没有承运商合同时只允许自运,与运单管理的承运类型收窄规则一致。
const carrierTypeOptions = computed(() => const carrierTypeOptions = computed(() =>
@@ -570,19 +604,47 @@ const resetCreateForm = () => {
const detailColumns = [ const detailColumns = [
{ prop: 'batchNo', label: '运单批次号', minWidth: 150 }, { prop: 'batchNo', label: '运单批次号', minWidth: 150 },
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 150 }, { prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 150 },
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 150 },
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号', editor: 'input', minWidth: 220 }, { 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: 'cargoName', label: '货物名称', editor: 'cargoName', minWidth: 220 },
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', 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 = [ const requiredDetailFields = [
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号' }, { prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号' },
{ prop: 'driverName', label: '司机/船长' }, { prop: 'transportType', label: '运输方式' },
{ prop: 'transportType', label: '运输类型' }, { prop: 'departureAddress', label: '发货地址' },
{ prop: 'arrivalAddress', label: '到货地址' },
{ prop: 'cargoName', label: '货物名称' }, { prop: 'cargoName', label: '货物名称' },
{ prop: 'cargoType', label: '货物类型' }, { prop: 'cargoType', label: '货物类型' },
{ prop: 'quantity', label: '数量' },
{ prop: 'quantityUnit', label: '数量单位' },
{ prop: 'actualStartDate', label: '实际发货时间' },
{ prop: 'actualEndDate', label: '实际完成时间' },
]; ];
const projectQueryParams = { approvalStatuses: 'approved,change_approved' }; const projectQueryParams = { approvalStatuses: 'approved,change_approved' };
const extractRecords = res => { const extractRecords = res => {
@@ -975,6 +1037,12 @@ const carrierChange = value => {
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || ''; if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
}; };
const fileChange = async (file, list) => { 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); files.value = list.slice(-1);
form.file = file.raw; form.file = file.raw;
const XLSX = await import('xlsx'); const XLSX = await import('xlsx');
@@ -988,25 +1056,35 @@ const fileChange = async (file, list) => {
); );
const keyMap = { const keyMap = {
originalNo: ['原始单号'], originalNo: ['原始单号'],
loadingIdentifier: ['配载标识号'],
vehicleNo: ['车牌号/航班号/船号/班列号'], vehicleNo: ['车牌号/航班号/船号/班列号'],
driverName: ['司机/船长'], transportType: ['运输方式', '运输类型'],
transportType: ['运输类型', '运输方式'], driverName: ['司机/船长姓名', '司机/船长'],
cargoName: ['货物名称'], driverPhone: ['司机/船长手机号'],
cargoType: ['货物类型'],
quantity: ['重量'],
departureAddress: ['发货地址'], departureAddress: ['发货地址'],
departureContact: ['发货联系人'], departureContact: ['发货联系人'],
departurePhone: ['发货联系人电话'], departurePhone: ['发货联系人电话'],
arrivalAddress: ['到货地址'], arrivalAddress: ['到货地址'],
arrivalContact: ['货联系人', '货联系人'], arrivalContact: ['货联系人', '货联系人'],
arrivalPhone: ['收货联系人电话'], arrivalPhone: ['收货联系人电话'],
startDate: ['开始时间'], cargoName: ['货物名称'],
endDate: ['结束时间'], cargoType: ['货物类型'],
packageType: ['包装'],
quantity: ['数量', '重量'],
quantityUnit: ['数量单位'],
specification: ['规格'],
model: ['型号'],
mileage: ['里程(km)', '里程'],
unitPrice: ['单价'], unitPrice: ['单价'],
freight: ['运费'], freight: ['运费'],
otherFeeTotal: ['其他费用合计'], otherFeeTotal: ['其他费用合计'],
freightTotal: ['运费合计'], freightTotal: ['运费合计'],
actualStartDate: ['实际发货时间', '开始时间'],
actualEndDate: ['实际完成时间', '结束时间'],
planStartDate: ['预计发货时间'],
planEndDate: ['预计完成时间'],
remark: ['备注'], remark: ['备注'],
waybillIdentifier: ['同一运单标识号'],
}; };
const normalizeImportDate = value => { const normalizeImportDate = value => {
if (value instanceof Date && !Number.isNaN(value.getTime())) { if (value instanceof Date && !Number.isNaN(value.getTime())) {
@@ -1025,8 +1103,10 @@ const fileChange = async (file, list) => {
item[key] ?? item[key] ??
''; '';
}); });
row.startDate = normalizeImportDate(row.startDate); row.actualStartDate = normalizeImportDate(row.actualStartDate);
row.endDate = normalizeImportDate(row.endDate); 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])); const duplicateKey = JSON.stringify(Object.keys(keyMap).map(key => row[key]));
count.set(duplicateKey, (count.get(duplicateKey) || 0) + 1); count.set(duplicateKey, (count.get(duplicateKey) || 0) + 1);
row._duplicateKey = duplicateKey; row._duplicateKey = duplicateKey;
@@ -1143,6 +1223,9 @@ const normalizeImportRow = row => ({
otherFeeTotal: normalizeOptionalImportNumber( otherFeeTotal: normalizeOptionalImportNumber(
firstNotEmpty(row.otherFeeTotal, row['其他费用合计']) firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
), ),
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
transportType: firstNotEmpty( transportType: firstNotEmpty(
row.transportType, row.transportType,
row['运输类型'], row['运输类型'],
@@ -4,10 +4,12 @@
'waybill-manage-page', 'waybill-manage-page',
{ {
'waybill-manage-page--form-page': isStandaloneWaybillFormPage, 'waybill-manage-page--form-page': isStandaloneWaybillFormPage,
'waybill-manage-page--detail-page': isStandaloneWaybillDetailPage,
}, },
]" ]"
> >
<component <component
v-if="!isStandaloneWaybillDetailPage"
:is="crudContainer" :is="crudContainer"
:option="pageFormOption" :option="pageFormOption"
:form-page-title="isStandaloneWaybillFormPage ? formPageTitle : undefined" :form-page-title="isStandaloneWaybillFormPage ? formPageTitle : undefined"
@@ -381,7 +383,7 @@
> >
<span>货物信息</span> <span>货物信息</span>
<div v-if="!dialogReadonly" class="waybill-manage-page__section-actions"> <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> <el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div> </div>
</div> </div>
@@ -1514,7 +1516,7 @@
v-model="attachmentRows" v-model="attachmentRows"
:readonly="dialogReadonly" :readonly="dialogReadonly"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
@change="handleAttachmentChange" @change="handleAttachmentChange"
@@ -1653,24 +1655,29 @@
</el-dialog> </el-dialog>
<empty-pagination <empty-pagination
v-show="!isStandaloneWaybillFormPage" v-show="!isStandaloneWaybillFormPage && !isStandaloneWaybillDetailPage"
:page="page" :page="page"
@size-change="sizeChange" @size-change="sizeChange"
@current-change="currentChange" @current-change="currentChange"
@load="onLoad(page, query)" @load="onLoad(page, query)"
/> />
<el-dialog <component
:is="detailContainer"
v-model="detailBox" v-model="detailBox"
:title="`${config.title}详情`" :title="`${config.title}详情`"
append-to-body :append-to-body="!isStandaloneWaybillDetailPage"
top="10px" top="10px"
width="96%" width="96%"
show-close 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"> <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"> <section-card class="waybill-manage-page__waybill-detail-summary">
<div class="waybill-manage-page__waybill-heading"> <div class="waybill-manage-page__waybill-heading">
<strong>运单详情</strong> <strong>运单详情</strong>
@@ -1775,7 +1782,6 @@
transportCargoRows.length ? transportCargoRows : parseJsonArray(detailRow.goodsJson) transportCargoRows.length ? transportCargoRows : parseJsonArray(detailRow.goodsJson)
" "
border border
height="220"
> >
<el-table-column type="index" label="序号" width="70" /> <el-table-column type="index" label="序号" width="70" />
<el-table-column prop="cargoName" label="货物名称" min-width="150" /> <el-table-column prop="cargoName" label="货物名称" min-width="150" />
@@ -1982,10 +1988,13 @@
</div> </div>
</template> </template>
</div> </div>
<template #footer> <div v-if="isStandaloneWaybillDetailPage" class="waybill-manage-page__detail-footer">
<el-button type="primary" @click="detailBox = false">关闭</el-button> <el-button type="primary" @click="closeDetail">关闭</el-button>
</div>
<template v-if="!isStandaloneWaybillDetailPage" #footer>
<el-button type="primary" @click="closeDetail">关闭</el-button>
</template> </template>
</el-dialog> </component>
<el-dialog <el-dialog
v-model="attachmentDocumentPreviewVisible" v-model="attachmentDocumentPreviewVisible"
@@ -2527,7 +2536,10 @@
搜索 搜索
</el-button> </el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
<div ref="transportAmap" class="waybill-manage-page__map"></div> <div ref="transportAmap" class="waybill-manage-page__map"></div>
</div>
<div class="waybill-manage-page__map-info"> <div class="waybill-manage-page__map-info">
<span>{{ transportMapStatus }}</span> <span>{{ transportMapStatus }}</span>
<span v-if="transportMapSelected.regionName" <span v-if="transportMapSelected.regionName"
@@ -2815,6 +2827,7 @@ const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader; let amapLoader;
const standaloneWaybillFormRoute = '/business/waybill-manage/form'; const standaloneWaybillFormRoute = '/business/waybill-manage/form';
const standaloneWaybillFormPaths = ['/business/waybill-manage', standaloneWaybillFormRoute]; const standaloneWaybillFormPaths = ['/business/waybill-manage', standaloneWaybillFormRoute];
const standaloneWaybillDetailRoute = '/business/waybill-manage/detail';
const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills'; const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills';
const attachmentViewerPlugins = [ 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 estimateMenuButtonCount = config => {
const rowActions = (config.actions || []).filter(action => action !== 'batchComplete'); const rowActions = (config.actions || []).filter(action => action !== 'batchComplete');
return 3 + rowActions.length + (config.operations || []).length; return 3 + rowActions.length + (config.operations || []).length;
@@ -2905,7 +2928,7 @@ const defaultTransportCargo = () => ({
remark: '', remark: '',
}); });
const taskCarrierTypes = ['承运商', '自运', '网货平台']; const taskCarrierTypes = ['承运商', '自运'];
// Avue 使 // Avue 使
// avue-form // avue-form
@@ -2955,6 +2978,7 @@ const getPlainAvueForm = avueForm => {
export default { export default {
components: { components: {
PageAvueForm, PageAvueForm,
PageDetail,
InfoFilled, InfoFilled,
Rank, Rank,
ElImageViewer, ElImageViewer,
@@ -2991,6 +3015,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
standaloneDetailPage: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@@ -3144,6 +3172,7 @@ export default {
transportMapKeyword: '', transportMapKeyword: '',
transportMapStatus: '可搜索地址或点击地图选点', transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {}, transportMapSelected: {},
transportMapSearchResults: [],
transportAmap: null, transportAmap: null,
transportAmapMarker: null, transportAmapMarker: null,
transportAmapGeocoder: null, transportAmapGeocoder: null,
@@ -3250,6 +3279,12 @@ export default {
isStandaloneWaybillFormPage() { isStandaloneWaybillFormPage() {
return this.isStandaloneWaybillPage && ['add', 'edit'].includes(this.$route.query.mode); 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() { formPageTitle() {
if (this.isStandaloneWaybillFormPage) { if (this.isStandaloneWaybillFormPage) {
return ( return (
@@ -3338,7 +3373,7 @@ export default {
return this.taskCarrierTypes; return this.taskCarrierTypes;
}, },
taskCarrierLabel() { taskCarrierLabel() {
return this.form.carrierType === '网货平台' ? '承运方' : '承运商'; return '承运商';
}, },
taskCarrierValue: { taskCarrierValue: {
get() { get() {
@@ -3533,10 +3568,16 @@ export default {
if (!this.isStandaloneWaybillFormPage) return; if (!this.isStandaloneWaybillFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add'; const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
const id = this.$route.query.id || ''; 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; if (this.standaloneFormKey === key) return;
this.standaloneFormKey = key; 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); this.beforeOpen(() => {}, mode);
}, },
buildFormGroupOption(option) { buildFormGroupOption(option) {
@@ -4034,7 +4075,8 @@ export default {
waybillUnitPrice(row = {}) { waybillUnitPrice(row = {}) {
const price = row.unitPrice || row.price || ''; const price = row.unitPrice || row.price || '';
const unit = row.priceUnit || row.billingUnit || ''; 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 = {}) { waybillCurrencyRemark(row = {}) {
const freight = this.parseJsonObject(row.freightJson); const freight = this.parseJsonObject(row.freightJson);
@@ -4066,6 +4108,10 @@ export default {
return { value: `${this.waybillCurrencyRemark(row)}${value}` }; return { value: `${this.waybillCurrencyRemark(row)}${value}` };
}, },
openDetail(row) { openDetail(row) {
if (!this.isStandaloneWaybillDetailPage) {
this.$router.push({ path: standaloneWaybillDetailRoute, query: { id: row.id } });
return;
}
this.detailBox = true; this.detailBox = true;
this.detailLoading = true; this.detailLoading = true;
this.detailRow = { ...row }; this.detailRow = { ...row };
@@ -4088,6 +4134,8 @@ export default {
this.detailRow = { this.detailRow = {
...detail, ...detail,
contractNo: contract.contractNo || detail.contractNo || detail.contractName || '', contractNo: contract.contractNo || detail.contractNo || detail.contractName || '',
customerName:
contract.partyA || contract.customerName || detail.customerName || '',
}; };
this.applyFormDetail(this.detailRow); this.applyFormDetail(this.detailRow);
return this.loadWaybillDetailProcessNodes(this.detailRow.projectId); return this.loadWaybillDetailProcessNodes(this.detailRow.projectId);
@@ -4108,6 +4156,14 @@ export default {
this.detailLoading = false; 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 = {}) { async openLoadingDetail(row = {}) {
let id = row.loadingId || row.loadingManageId || ''; let id = row.loadingId || row.loadingManageId || '';
if (!id && row.loadingNo) { if (!id && row.loadingNo) {
@@ -4744,6 +4800,30 @@ export default {
beforeOpen(done, type) { beforeOpen(done, type) {
this.crudDialogType = type; this.crudDialogType = type;
this.dialogReadonly = type === 'view'; 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') { if (type === 'add') {
this.form = { this.form = {
...(this.config.defaultForm || {}), ...(this.config.defaultForm || {}),
@@ -4998,7 +5078,11 @@ export default {
this.form.settlementCurrency = this.form.settlementCurrency =
contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || ''; contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || '';
this.form.customerName = this.form.customerName =
contract.customerName || contract.customerNames || this.form.customerName || ''; contract.partyA ||
contract.customerName ||
contract.customerNames ||
this.form.customerName ||
'';
this.syncTaskFreightCurrencyFromContract(contract); this.syncTaskFreightCurrencyFromContract(contract);
this.fillSelfOperatedCarrierFromContract(contract); this.fillSelfOperatedCarrierFromContract(contract);
this.$refs.crud?.validateField?.('contractName'); this.$refs.crud?.validateField?.('contractName');
@@ -5409,7 +5493,7 @@ export default {
this.form.carrierId = ''; this.form.carrierId = '';
}, },
isTaskCarrierRequired(carrierType) { isTaskCarrierRequired(carrierType) {
return ['承运商', '网货平台'].includes(String(carrierType || '').trim()); return String(carrierType || '').trim() === '承运商';
}, },
taskCarrierOptionValue(item = {}) { taskCarrierOptionValue(item = {}) {
if (this.form.carrierType !== '自运') { if (this.form.carrierType !== '自运') {
@@ -5986,7 +6070,7 @@ export default {
['vehicleNo', this.transportVehicleNoLabel], ['vehicleNo', this.transportVehicleNoLabel],
]; ];
if (this.isTaskCarrierRequired(row.carrierType)) { if (this.isTaskCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']); requiredFields.push(['carrierName', '承运商']);
} }
if (row.carrierType === '承运商') { if (row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']); requiredFields.push(['carrierContractId', '承运商合同']);
@@ -6805,6 +6889,12 @@ export default {
this.ensureTransportAmapGeocoder() this.ensureTransportAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword)) .then(() => this.runAmapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveMapPoint(result);
if (!point) { if (!point) {
this.transportMapStatus = '未找到匹配地址'; this.transportMapStatus = '未找到匹配地址';
@@ -6825,6 +6915,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗'); this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
}); });
}, },
selectTransportMapSearchResult(item) {
if (item && item.location) {
this.pickTransportMapPoint(item.location, item.address || item.name || '');
}
},
pickTransportMapPoint(lnglat, keyword) { pickTransportMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat); const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat); const latitude = this.getPointLat(lnglat);
@@ -7559,6 +7654,18 @@ export default {
openImportDialog(this, this.config.title, () => this.onLoad(this.page, this.query)); openImportDialog(this, this.config.title, () => this.onLoad(this.page, this.query));
}, },
handleCopy(row) { 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}?`, '提示', { this.$confirm(`确定复制该${this.config.title}?`, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', 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 { &__waybill-detail-summary {
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 18px 22px; padding: 18px 22px;
@@ -8835,10 +8953,6 @@ export default {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
.section-card {
min-height: 300px;
}
} }
&__waybill-map-empty { &__waybill-map-empty {
@@ -36,7 +36,7 @@
</div> </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> <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"> <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> </div>
</section> </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="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-column label="操作" width="100"><template #default="{ $index }"><el-link type="danger" @click="changeMaterials.splice($index, 1)">删除</el-link></template></el-table-column>
</el-table> </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> </section>
<div class="page-footer"> <div class="page-footer">
<el-button @click="$router.back()">取消</el-button> <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; }); }, 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 = ''; }, 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 = ''; }, 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') })); }, 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 || ''; }, attachmentUrl(row = {}) { return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || ''; },
attachmentName(row = {}) { return row.originalName || row.name || row.fileName || '附件'; }, attachmentName(row = {}) { return row.originalName || row.name || row.fileName || '附件'; },
+2 -2
View File
@@ -1158,9 +1158,9 @@ const AttachmentSection = defineComponent({
h(VehicleAttachmentUpload, { h(VehicleAttachmentUpload, {
modelValue: this.rows, modelValue: this.rows,
fileTypes: this.attachmentFileTypes, fileTypes: this.attachmentFileTypes,
maxSize: 500, maxSize: 50,
showTip: true, 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, showFileList: false,
buttonText: '上传附件', buttonText: '上传附件',
'onUpdate:modelValue': this.handleChange, 'onUpdate:modelValue': this.handleChange,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -587,10 +587,10 @@
class="project-apply-form__upload" class="project-apply-form__upload"
:readonly="dialogReadonly" :readonly="dialogReadonly"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
tip="支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过500M" tip="支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过50M"
@change="handleAttachmentChange" @change="handleAttachmentChange"
/> />
</div> </div>
@@ -117,10 +117,10 @@
v-model="attachmentRows" v-model="attachmentRows"
:readonly="dialogReadonly" :readonly="dialogReadonly"
:file-types="attachmentFileTypes" :file-types="attachmentFileTypes"
:max-size="500" :max-size="50"
:show-file-list="false" :show-file-list="false"
button-text="上传附件" button-text="上传附件"
tip="支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过500M" tip="支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过50M"
@change="handleAttachmentChange" @change="handleAttachmentChange"
/> />
</div> </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>
+435 -103
View File
@@ -22,13 +22,13 @@
><el-input v-model="query.waybillBatchNo" clearable placeholder="请输入" ><el-input v-model="query.waybillBatchNo" clearable placeholder="请输入"
/></el-form-item> /></el-form-item>
<template v-if="searchExpanded"> <template v-if="searchExpanded">
<el-form-item label="上传来源" <el-form-item v-if="!isCarrier" label="上传来源"
><el-select v-model="query.uploadSource" clearable placeholder="全部" ><el-select v-model="query.uploadSource" clearable placeholder="全部"
><el-option label="内部" value="内部" /><el-option ><el-option label="内部" value="内部" /><el-option
label="承运商" label="承运商"
value="承运商" /></el-select value="承运商" /></el-select
></el-form-item> ></el-form-item>
<el-form-item label="承运商" <el-form-item v-if="!isCarrier" label="承运商"
><el-input v-model="query.carrierName" clearable placeholder="请输入" ><el-input v-model="query.carrierName" clearable placeholder="请输入"
/></el-form-item> /></el-form-item>
<el-form-item label="创建日期" <el-form-item label="创建日期"
@@ -77,7 +77,8 @@
min-width="200" min-width="200"
show-overflow-tooltip 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" prop="carrierName"
label="承运商名称" label="承运商名称"
min-width="160" min-width="160"
@@ -99,13 +100,19 @@
width="120" width="120"
fixed="right" fixed="right"
align="center" 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 }" ><template #default="{ row }"
><div class="voucher-manage-page__actions"> ><div class="voucher-manage-page__actions">
<el-link <el-link
v-if="row.processStatus === '处理完成' && row.auditStatus === '待审核'" v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
type="primary" 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 v-if="row.processStatus === '处理完成'" type="primary" @click="view(row)"
>查看</el-link >查看</el-link
><el-link ><el-link
@@ -142,63 +149,60 @@
</section> </section>
</basic-container> </basic-container>
<el-dialog v-model="detailVisible" width="960px" destroy-on-close> <el-dialog
<template #header> v-model="detailVisible"
<div class="voucher-manage-page__dialog-title">凭证详情</div> title="执行凭证详情"
</template> width="92%"
<el-descriptions v-loading="detailLoading" :column="2" border class="voucher-manage-page__detail-desc"> top="5vh"
<el-descriptions-item label="项目名称"> class="voucher-detail-dialog"
{{ detailValue(detailRow.projectName) }} destroy-on-close
</el-descriptions-item> >
<el-descriptions-item label="凭证批次号"> <div v-loading="detailLoading" class="voucher-manage-page__detail">
{{ detailValue(detailRow.voucherBatchNo) }} <div class="voucher-manage-page__detail-summary">
</el-descriptions-item> <div class="voucher-manage-page__detail-field">
<el-descriptions-item label="运单批次号" :span="2"> <span class="voucher-manage-page__detail-label">执行凭证号</span>
{{ detailValue(detailRow.waybillBatchNo) }} <span class="voucher-manage-page__detail-value">{{ detailVoucherNo }}</span>
</el-descriptions-item> </div>
<el-descriptions-item label="文件名称" :span="2"> <div class="voucher-manage-page__detail-field">
<el-link v-if="detailRow.fileUrl" type="primary" @click="download(detailRow)"> <span class="voucher-manage-page__detail-label">文件夹名称</span>
{{ detailValue(detailRow.fileName) }} <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> </el-link>
<template v-else>{{ detailValue(detailRow.fileName) }}</template> <span v-else class="voucher-manage-page__detail-value">-</span>
</el-descriptions-item> </div>
<el-descriptions-item label="上传来源"> </div>
{{ detailValue(detailRow.uploadSource) }} <div v-if="detailImages.length" class="voucher-manage-page__detail-images">
</el-descriptions-item> <el-image
<el-descriptions-item label="承运商名称"> v-for="(image, index) in detailImages"
{{ detailValue(detailRow.carrierName) }} :key="image.url || index"
</el-descriptions-item> class="voucher-manage-page__detail-image"
<el-descriptions-item label="处理状态"> :src="image.url"
{{ detailValue(detailRow.processStatus) }} :preview-src-list="detailImageUrls"
</el-descriptions-item> :initial-index="index"
<el-descriptions-item label="审核状态"> fit="cover"
{{ detailValue(detailRow.auditStatus) }} preview-teleported
</el-descriptions-item> />
<el-descriptions-item label="凭证数量"> </div>
{{ detailValue(detailRow.voucherCount) }} <div v-if="detailOtherFiles.length" class="voucher-manage-page__detail-files">
</el-descriptions-item> <div v-for="file in detailOtherFiles" :key="file.id || file.url || file.name" class="voucher-manage-page__detail-file">
<el-descriptions-item label="已关联运单"> <span class="voucher-manage-page__detail-file-name">{{ file.name }}</span>
{{ detailValue(detailRow.relatedWaybillCount) }} <el-link v-if="file.url" type="primary" @click="openDetailFile(file)">查看</el-link>
</el-descriptions-item> </div>
<el-descriptions-item label="未关联运单"> </div>
{{ detailValue(detailRow.unRelatedWaybillCount) }} <el-empty v-if="!detailImages.length && !detailOtherFiles.length" description="暂无凭证文件" :image-size="80" />
</el-descriptions-item> </div>
<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> </el-dialog>
<el-dialog <el-dialog
@@ -217,7 +221,7 @@
label-width="auto" label-width="auto"
class="voucher-manage-page__upload-form" class="voucher-manage-page__upload-form"
> >
<el-form-item label="项目名称" prop="projectId" <el-form-item v-if="!isCarrier" label="项目名称" prop="projectId"
><el-select ><el-select
v-model="editing.projectId" v-model="editing.projectId"
class="voucher-manage-page__project-select" class="voucher-manage-page__project-select"
@@ -231,7 +235,7 @@
:label="item.projectName" :label="item.projectName"
:value="item.id" /></el-select :value="item.id" /></el-select
></el-form-item> ></el-form-item>
<el-form-item label="执行凭证" prop="fileUrl" <el-form-item label="执行凭证" required
><el-upload ><el-upload
action="#" action="#"
accept=".zip,.7z" accept=".zip,.7z"
@@ -246,7 +250,7 @@
>上传</el-button >上传</el-button
><template #tip ><template #tip
><span class="voucher-manage-page__upload-tip" ><span class="voucher-manage-page__upload-tip"
>支持 zip7z 格式采用分片上传上传中断后可在上传进度继续上传</span >请上传车辆的执行凭证压缩包支持zip7z格式 上传附件大于50M时为超大附件确认提交后可在批量补录->上传任务中查看上传进度</span
></template ></template
></el-upload ></el-upload
><el-progress ><el-progress
@@ -325,8 +329,9 @@
ref="batchTableRef" ref="batchTableRef"
:data="batchRows" :data="batchRows"
border border
@selection-change="batchSelection = $event" highlight-current-row
><el-table-column type="selection" width="56" /><el-table-column @current-change="batchSelection = $event ? [$event] : []"
><el-table-column
type="index" type="index"
label="序号" label="序号"
width="80" /><el-table-column width="80" /><el-table-column
@@ -337,7 +342,13 @@
label="创建时间" label="创建时间"
min-width="180" /><el-table-column label="运单数" width="120" min-width="180" /><el-table-column label="运单数" width="120"
><template #default="{ row }">{{ row.waybillCount ?? '-' }}</template></el-table-column ><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> /></el-table>
<div class="voucher-manage-page__pagination"> <div class="voucher-manage-page__pagination">
<el-pagination <el-pagination
@@ -458,13 +469,39 @@
</template> </template>
<script setup> <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 md5 from 'js-md5';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { getList as getProjectList } from '@/api/business/project-apply'; import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage'; import * as api from '@/api/business/voucher-manage';
import SectionCard from '@/components/section-card/main.vue'; 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), const loading = ref(false),
rows = ref([]), rows = ref([]),
createRange = ref([]), createRange = ref([]),
@@ -834,6 +871,16 @@ const resetBatchQuery = () => {
batchPage.current = 1; batchPage.current = 1;
loadBatches(); 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 confirmBatches = () => {
const batches = new Map(); const batches = new Map();
batchSelection.value.forEach(item => { batchSelection.value.forEach(item => {
@@ -935,24 +982,218 @@ const removeRow = row =>
}); });
const detailValue = value => const detailValue = value =>
value === undefined || value === null || value === '' ? '-' : value; value === undefined || value === null || value === '' ? '-' : value;
const view = async row => { const firstDetailValue = (row, keys) => {
detailVisible.value = true; for (const key of keys) {
detailLoading.value = true; const value = row?.[key];
detailRow.value = { ...row }; if (value !== undefined && value !== null && value !== '') return value;
try {
const res = await api.getDetail(row.id);
detailRow.value = res.data?.data || row;
} catch (error) {
ElMessage.error(error.message || '获取凭证详情失败');
} finally {
detailLoading.value = false;
} }
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 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(); load();
</script> </script>
<style lang="scss" scoped> <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 { .voucher-manage-page {
&__search { &__search {
padding: 12px 12px 4px; padding: 12px 12px 4px;
@@ -986,21 +1227,6 @@ load();
padding: 0; padding: 0;
min-height: auto; 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 { &__actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1025,6 +1251,20 @@ load();
margin-left: 16px; margin-left: 16px;
color: #606266; 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 { &__progress-cell {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1076,16 +1316,108 @@ load();
:deep(.el-table__body tr:nth-child(even) > td) { :deep(.el-table__body tr:nth-child(even) > td) {
background: #fafafa; background: #fafafa;
} }
&__detail-desc { &__detail {
:deep(.el-descriptions__table) { min-height: 420px;
table-layout: fixed; padding: 40px 46px 68px;
th, td { box-sizing: border-box;
width: 50%;
} }
.el-descriptions__label, &__detail-summary {
.el-descriptions__content { display: grid;
width: 50%; 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; return;
} }
this.$router.push({ this.$router.push({
path: '/business/waybill-manage', path: '/business/waybill-manage/detail',
query: { detailId: waybillId }, query: { id: waybillId },
}); });
}, },
restoreDetailSelection() { restoreDetailSelection() {
@@ -610,12 +610,30 @@
</div> </div>
<template v-if="!pageMode" #footer> <template v-if="!pageMode" #footer>
<el-button @click="visible = false">取消</el-button> <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 v-if="editable" type="primary" :loading="saving" @click="handleSave"
>提交</el-button >提交</el-button
> >
</template> </template>
<div v-if="pageMode" class="formal-editor__page-actions"> <div v-if="pageMode" class="formal-editor__page-actions">
<el-button @click="visible = false">取消</el-button> <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 v-if="editable" type="primary" :loading="saving" @click="handleSave">
提交 提交
</el-button> </el-button>
@@ -964,6 +982,7 @@ export default {
data() { data() {
return { return {
loading: false, loading: false,
draftSaving: false,
saving: false, saving: false,
form: createFormalSettlementForm(), form: createFormalSettlementForm(),
sources: [], sources: [],
@@ -2396,6 +2415,20 @@ export default {
remark: this.form.remark, 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() { async handleSave() {
await this.$refs.formRef.validate(); await this.$refs.formRef.validate();
if (!this.form.sourcePreSettlementIds.length && !this.form.sourceDetailIds.length) { if (!this.form.sourcePreSettlementIds.length && !this.form.sourceDetailIds.length) {
@@ -150,7 +150,18 @@
>{{ updateName(row.updateResult) }}</el-tag >{{ updateName(row.updateResult) }}</el-tag
> >
<span v-else-if="column.prop === 'matchedExternalLineNo'"> <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>
<span v-else-if="column.prop === 'transportType'"> <span v-else-if="column.prop === 'transportType'">
{{ transportTypeLabel(row.transportType) }} {{ transportTypeLabel(row.transportType) }}
@@ -422,6 +433,129 @@
> >
</el-dialog> </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> </component>
</template> </template>
@@ -430,6 +564,7 @@ import { mapGetters } from 'vuex';
import * as api from '@/api/settlement/transportReconciliation'; import * as api from '@/api/settlement/transportReconciliation';
import * as formalSettlementApi from '@/api/settlement/formalSettlement'; import * as formalSettlementApi from '@/api/settlement/formalSettlement';
import { getDictionary } from '@/api/system/dictbiz'; import { getDictionary } from '@/api/system/dictbiz';
import { Search } from '@element-plus/icons-vue';
import { downloadXls } from '@/utils/util'; import { downloadXls } from '@/utils/util';
import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm'; import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm';
import { import {
@@ -450,6 +585,7 @@ export default {
emits: ['update:modelValue', 'success'], emits: ['update:modelValue', 'success'],
data() { data() {
return { return {
Search,
loading: false, loading: false,
saving: false, saving: false,
actionLoading: false, actionLoading: false,
@@ -472,6 +608,8 @@ export default {
page: { current: 1, size: 10, total: 0 }, page: { current: 1, size: 10, total: 0 },
}, },
adjustDialog: { visible: false, saving: false, rows: [], reason: '' }, adjustDialog: { visible: false, saving: false, rows: [], reason: '' },
manualMatchDialog: { visible: false, loading: false, row: null },
completionDialog: { visible: false, loading: false },
externalEditing: {}, externalEditing: {},
externalEditSnapshots: {}, externalEditSnapshots: {},
matchingStarted: false, matchingStarted: false,
@@ -530,6 +668,9 @@ export default {
); );
}, },
groupedInternalRows() { groupedInternalRows() {
if (this.form.reconciliationMode === 'cargo') {
return this.internalDetails.map(row => ({ ...row, _sourceRows: [row] }));
}
const groups = new Map(); const groups = new Map();
this.internalDetails.forEach((row, index) => { this.internalDetails.forEach((row, index) => {
const key = row.documentNo ? String(row.documentNo) : `__row_${index}`; const key = row.documentNo ? String(row.documentNo) : `__row_${index}`;
@@ -597,6 +738,72 @@ export default {
visibleExternalRows() { visibleExternalRows() {
return this.externalTab === 'duplicate' ? this.duplicateRows : this.externalDetails; 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: { watch: {
modelValue: { modelValue: {
@@ -928,12 +1135,45 @@ export default {
this.saving = false; 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() { async handleMatch() {
if (!this.currentId) return this.$message.warning('请先保存草稿后再匹配'); if (!this.currentId) return this.$message.warning('请先保存草稿后再匹配');
this.actionLoading = true; this.actionLoading = true;
try { try {
await api.match(this.currentId); const data = this.unwrapData(
await this.loadDetail(); 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.matchingStarted = true;
this.$message.success('匹配完成'); this.$message.success('匹配完成');
} finally { } finally {
@@ -944,8 +1184,6 @@ export default {
await this.$confirm('将以外部账单匹配金额更新内部结算明细,是否继续?', '更新账单', { await this.$confirm('将以外部账单匹配金额更新内部结算明细,是否继续?', '更新账单', {
type: 'warning', type: 'warning',
}); });
const savedId = await this.handleSave(true);
if (!savedId) return;
this.actionLoading = true; this.actionLoading = true;
try { try {
await api.updateByMatch(this.currentId); await api.updateByMatch(this.currentId);
@@ -963,22 +1201,68 @@ export default {
) { ) {
return this.$message.warning('差异单数、差异货量和差异金额必须全部为0才可完成对账'); return this.$message.warning('差异单数、差异货量和差异金额必须全部为0才可完成对账');
} }
await this.$confirm('完成后对账单不可修改和删除,是否继续?', '完成对账', { this.completionDialog.visible = true;
type: 'warning', },
}); async confirmCompletion() {
const savedId = await this.handleSave(true); if (this.completionDialog.loading) return;
if (!savedId) return; this.completionDialog.loading = true;
this.actionLoading = true;
try { try {
await api.complete(this.currentId); this.actionLoading = true;
await this.loadDetail(); 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.$message.success('对账完成');
this.completionDialog.visible = false;
this.visible = false; this.visible = false;
this.$emit('success', this.currentId); this.$emit('success', this.currentId);
} finally { } finally {
this.completionDialog.loading = false;
this.actionLoading = 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() { chooseImport() {
this.$refs.fileInput?.click(); this.$refs.fileInput?.click();
}, },
@@ -987,10 +1271,8 @@ export default {
event.target.value = ''; event.target.value = '';
if (!file) return; if (!file) return;
try { try {
if (!this.currentId) {
const savedId = await this.handleSave(true); const savedId = await this.handleSave(true);
if (!savedId) return; if (!savedId) return;
}
const response = const response =
this.form.reconciliationMode === 'cargo' this.form.reconciliationMode === 'cargo'
? await api.importCargo(this.currentId, file) ? await api.importCargo(this.currentId, file)
@@ -1039,23 +1321,96 @@ export default {
if (!this.adjustDialog.reason.trim()) return this.$message.warning('请输入调整原因'); if (!this.adjustDialog.reason.trim()) return this.$message.warning('请输入调整原因');
this.adjustDialog.saving = true; this.adjustDialog.saving = true;
try { try {
for (const row of this.adjustDialog.rows) const rowsById = new Map(this.adjustDialog.rows.map(row => [row.id, row]));
await api.adjust({ this.internalDetails = this.internalDetails.map(row => {
const adjusted = rowsById.get(row.id);
if (!adjusted) return row;
return {
...row, ...row,
reconciliationId: this.currentId, transportQuantity: adjusted.transportQuantity,
updateMessage: this.adjustDialog.reason, 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; this.adjustDialog.visible = false;
await this.loadDetail(); this.refreshLocalStats();
this.$message.success('调整保存成功'); this.$message.success('调整已暂存');
} finally { } finally {
this.adjustDialog.saving = false; this.adjustDialog.saving = false;
} }
}, },
async handleUnmatch(row) { async handleUnmatch(row) {
const matchedRows = (row._sourceRows || [row]).filter(item => item.matchResult === 'matched'); const matchedRows = (row._sourceRows || [row]).filter(item => item.matchResult === 'matched');
for (const item of matchedRows) await api.unmatch(item.id); matchedRows.forEach(item => {
await this.loadDetail(); 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 }) { internalRowClassName({ row }) {
return this.matchingStarted && row.matchResult !== 'matched' return this.matchingStarted && row.matchResult !== 'matched'
@@ -1101,7 +1456,7 @@ export default {
this.externalEditing = editing; this.externalEditing = editing;
this.externalEditSnapshots = snapshots; this.externalEditSnapshots = snapshots;
}, },
finishExternalAdjust(row) { async finishExternalAdjust(row) {
row.feeItemsJson = JSON.stringify(row.feeItems || {}); row.feeItemsJson = JSON.stringify(row.feeItems || {});
const key = this.externalRowKey(row); const key = this.externalRowKey(row);
const editing = { ...this.externalEditing }; const editing = { ...this.externalEditing };
@@ -1110,9 +1465,23 @@ export default {
delete snapshots[key]; delete snapshots[key];
this.externalEditing = editing; this.externalEditing = editing;
this.externalEditSnapshots = snapshots; 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( const externalQuantity = this.externalDetails.reduce(
(total, item) => total + Number(item.transportQuantity || 0), (total, item) => total + Number(item.transportQuantity || 0),
0 0
@@ -1121,18 +1490,37 @@ export default {
(total, item) => total + Number(item.settlementAmount || 0), (total, item) => total + Number(item.settlementAmount || 0),
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 = {
...this.form, ...this.form,
internalBillCount: this.internalDetails.length,
externalBillCount: this.externalDetails.length, externalBillCount: this.externalDetails.length,
internalQuantity,
internalAmount,
externalQuantity, externalQuantity,
externalAmount, externalAmount,
differenceQuantity: Math.abs(Number(this.form.internalQuantity || 0) - externalQuantity), differenceCount:
differenceAmount: Math.abs(Number(this.form.internalAmount || 0) - externalAmount), Math.abs(this.internalDetails.length - this.externalDetails.length) +
Math.min(internalUnmatched, externalUnmatched),
differenceQuantity: Math.abs(internalQuantity - externalQuantity),
differenceAmount: Math.abs(internalAmount - externalAmount),
matchedCount, 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) { matchName(value) {
return ( return (
{ {
@@ -1303,6 +1691,99 @@ export default {
.dialog-form { .dialog-form {
margin-top: 16px; 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 { .hidden-file {
display: none; display: none;
} }
@@ -1336,5 +1817,26 @@ export default {
.reconciliation-editor__filter { .reconciliation-editor__filter {
grid-template-columns: repeat(2, minmax(160px, 1fr)); 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> </style>
@@ -1694,8 +1694,8 @@ export default {
return; return;
} }
this.$router.push({ this.$router.push({
path: '/business/waybill-manage', path: '/business/waybill-manage/detail',
query: { detailId: row.waybillId }, query: { id: row.waybillId },
}); });
}, },
async openDetailDialog(row) { async openDetailDialog(row) {
+11 -10
View File
@@ -443,18 +443,22 @@ export default {
}, },
computed: { computed: {
...mapGetters(['userInfo', 'permission']), ...mapGetters(['userInfo', 'permission']),
// checkStrictly // checkStrictly false
deptCascaderProps() { deptCascaderProps() {
return { return {
label: 'title', label: 'title',
value: 'id', value: 'id',
children: 'children', children: 'children',
emitPath: true, emitPath: false,
checkStrictly: true, checkStrictly: false,
expandTrigger: 'click', expandTrigger: 'click',
leaf: (data, node) => {
// children children
return !data.children || data.children.length === 0;
},
}; };
}, },
// form.deptId join id // form.deptId join id
deptCascaderValue: { deptCascaderValue: {
get() { get() {
const ids = Array.isArray(this.form.deptId) const ids = Array.isArray(this.form.deptId)
@@ -463,13 +467,10 @@ export default {
? [this.form.deptId] ? [this.form.deptId]
: []; : [];
const id = ids.find(item => item !== undefined && item !== null && item !== ''); const id = ids.find(item => item !== undefined && item !== null && item !== '');
return id === undefined ? [] : this.findDeptPath(id) || [id]; return id === undefined ? '' : id;
}, },
set(path) { set(value) {
const value = Array.isArray(path) this.form.deptId = value ? [value] : [];
? path.filter(item => item !== undefined && item !== null && item !== '')
: [];
this.form.deptId = value.length ? [value.at(-1)] : [];
this.$refs.userFormRef?.validateField('deptId'); this.$refs.userFormRef?.validateField('deptId');
}, },
}, },
+4 -1
View File
@@ -383,7 +383,10 @@ export default {
}, },
openWaybill(row) { openWaybill(row) {
if (row.waybillId) { 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) { decorateRow(row) {
+4 -1
View File
@@ -347,7 +347,10 @@ export default {
}, },
openWaybill(row) { openWaybill(row) {
if (row.waybillId) { 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) { decorateRow(row) {
+15
View File
@@ -929,7 +929,10 @@
搜索 搜索
</el-button> </el-button>
</div> </div>
<div class="map-picker-content">
<map-search-results :results="contactMapSearchResults" @select="selectContactMapSearchResult" />
<div ref="contactAmap" class="contact-form__map"></div> <div ref="contactAmap" class="contact-form__map"></div>
</div>
<div class="contact-form__map-info"> <div class="contact-form__map-info">
<span>{{ contactMapStatus }}</span> <span>{{ contactMapStatus }}</span>
<span v-if="contactMapSelected.regionName"> <span v-if="contactMapSelected.regionName">
@@ -1590,6 +1593,7 @@ export default {
contactMapKeyword: '', contactMapKeyword: '',
contactMapStatus: '可搜索地址或点击地图选点', contactMapStatus: '可搜索地址或点击地图选点',
contactMapSelected: {}, contactMapSelected: {},
contactMapSearchResults: [],
contactMapTarget: 'contact', contactMapTarget: 'contact',
contactAmap: null, contactAmap: null,
contactAmapMarker: null, contactAmapMarker: null,
@@ -2995,6 +2999,12 @@ export default {
this.ensureContactAmapGeocoder() this.ensureContactAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword)) .then(() => this.runAmapGeocode('location', keyword))
.then(result => { .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); const point = this.resolveMapPoint(result);
if (!point) { if (!point) {
this.contactMapStatus = '未找到匹配地址'; this.contactMapStatus = '未找到匹配地址';
@@ -3015,6 +3025,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗'); this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
}); });
}, },
selectContactMapSearchResult(item) {
if (item && item.location) {
this.pickContactMapPoint(item.location, item.address || item.name || '');
}
},
pickContactMapPoint(lnglat, keyword) { pickContactMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat); const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat); const latitude = this.getPointLat(lnglat);
+300
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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分钟(不含用户培训)
**建议部署时间:** 非业务高峰期