@@ -1,5 +1,6 @@
< template >
< component
v-if = "!createPage"
: is = "standalone ? 'div' : 'el-dialog'"
v-model = "visible"
title = "导入运单"
@@ -44,7 +45,7 @@
< / div >
< / div >
< div class = "waybill-import-toolbar__actions" >
< el-button type = "primary" @click ="open Create" > 新建导入 < / el -button
< el-button type = "primary" @click ="handle Create" > 新建导入 < / el -button
> < el-button type = "danger" plain :disabled = "!selected.length" @click ="removeBatches"
> 批量删除 < / e l - b u t t o n
>
@@ -84,7 +85,11 @@
>
< template # default = "{ row }"
> < el-link type = "primary" @click ="openDetail(row)" > 查看 < / el -link
> < el-link type = "primary" @click ="editBatch(row)" > 编辑 < / el -link
> < el-link
v-if = "row.importStatus === 'draft'"
type = "primary"
@click ="editBatch(row)"
> 编辑 < / e l - l i n k
> < el-link type = "danger" @click ="removeBatch(row)" > 删除 < / el -link > < / t e m p l a t e
>
< / el-table-column >
@@ -173,13 +178,14 @@
/ >
< / el-dialog >
< el-dialog
< component
: is = "createPage ? 'div' : 'el-dialog'"
v-model = "createVisible"
title = "新建导入"
width = "85%"
append -to -body
destroy -on -close
class = "waybill-import-create"
: class = "[' waybill-import-create', { 'waybill-import-create--page': createPage }] "
>
< section class = "waybill-import-create__form-panel" >
< el-form
@@ -236,9 +242,12 @@
< el-col :span = "6" >
< el-form-item label = "承运类型" prop = "carrierType" >
< el-select v-model = "form.carrierType" @change="carrierTypeChange" >
< el -option label = "承运商" value = "承运商" / >
< el-option label = "自运" value = "自运" / >
< el-option label = "网货平台" value = "网货平台" / >
< el -option
v-for = "item in carrierTypeOptions"
:key = "item"
:label = "item"
:value = "item"
/ >
< / el-select >
< / el-form-item >
< / el-col >
@@ -267,8 +276,12 @@
< el-col :span = "6" >
< el-form-item label = "导入状态" prop = "status" >
< el-select v-model = "form.status" >
< el -option label = "完成" value = "completed" / >
< el-option label = "进行中" value = "processing" / >
< el -option
v-for = "item in importStatusOptions"
:key = "item.value"
:label = "item.label"
:value = "item.value"
/ >
< / el-select >
< / el-form-item >
< / el-col >
@@ -307,8 +320,8 @@
> < el-link type = "primary" @click ="downloadTemplate" > 下载模板 < / el -link > < / e l - f o r m - i t e m
>
< / el-form >
< div class = "waybill-import-create__form-actions" >
< el-button @click ="createVisible = fals e" > 关闭 < / el -button
< div v-if = "!createPage" class=" waybill-import-create__form-actions" >
< el -button @click ="closeCreat e" > 关闭 < / el -button
> < el-button @click ="saveDraft" > 保存草稿 < / el -button
> < el-button type = "primary" @click ="confirmImport" > 确认导入 < / el -button >
< / div >
@@ -373,7 +386,8 @@
clearable
filterable
placeholder = "请选择"
@change ="value => handleEditorChange(column, row, value)" / > < el-select
@change ="value => handleEditorChange(column, row, value)"
/ > < el-select
v-else-if = "column.editor === 'cargoName'"
v-model = "row.cargoName"
clearable
@@ -392,20 +406,24 @@
type=" date "
format=" YYYY - MM - DD "
value-format=" YYYY - MM - DD "
placeholder=" 请选择 " /><el-input
placeholder=" 请选择 "
/><el-input
v-else-if=" column . editor === 'textarea' "
v-model=" row [ column . prop ] "
type=" textarea "
:autosize=" { minRows : 1 , maxRows : 3 } "
:placeholder=" ` 请输入 ${ column . label } ` " /><el-input
:placeholder=" ` 请输入 ${ column . label } ` "
/><el-input
v-else-if=" column . editor === 'number' "
:model-value=" row [ column . prop ] "
inputmode=" decimal "
:placeholder=" ` 请输入 ${ column . label } ` "
@input=" value => updateEditorValue ( column , row , value ) " /><el-input
v-else
@input=" value => updateEditorValue ( column , row , value ) "
/><el-input
v-else-if=" column . editor "
v-model=" row [ column . prop ] "
:placeholder=" ` 请输入 ${ column . label } ` " /></template
:placeholder=" ` 请输入 ${ column . label } ` "
/><span v-else>{{ formatCell(row, column) }}</span></template
><span v-else>{{ formatCell(row, column) }}</span></template
></el-table-column
><el-table-column label=" 操作 " width=" 180 " fixed=" right "
@@ -421,12 +439,19 @@
></el-table
>
</section>
</el-dialog >
<div v-if=" createPage " class=" waybill - import - create _ _footer " >
<el-button @click=" closeCreate ">取消</el-button>
<el-button @click=" saveDraft ">保存草稿</el-button>
<el-button type=" primary " @click=" confirmImport ">确认导入</el-button>
</div>
</component>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import dayjs from 'dayjs';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import { getList as getContractList } from '@/api/business/contract-manage';
@@ -436,19 +461,22 @@ import { getDictionary } from '@/api/system/dictbiz';
import * as api from '@/api/business/waybill-manage';
import { downloadXls } from '@/utils/util';
const props = defineProps({ modelValue: Boolean, standalone: Boolean });
const props = defineProps({ modelValue: Boolean, standalone: Boolean, createPage: Boolean });
const emit = defineEmits(['update:modelValue', 'closed']);
const router = useRouter();
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
const handleClosed = () => emit('closed');
const createVisible = ref(fals e),
const createVisible = ref(props.createPag e),
detailVisible = ref(false),
formRef = ref();
const query = reactive({ batchNo: '', carrierId: '', createUser: '', createTimeRange: [] });
const detailQuery = reactive({ vehicleNo: '', cargoName: '' });
const createDefaultForm = () => ({
id: '',
batchNo: '',
projectId: '',
projectName: '',
customerId: '',
@@ -466,6 +494,14 @@ const createDefaultForm = () => ({
file: null,
});
const form = reactive(createDefaultForm());
// 批量导入状态仅保留草稿与导入完成两种,与后端 importStatus 取值一致。
const importStatusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '导入完成', value: 'completed' },
];
// 编辑草稿时明细已入库,此时不再强制重新上传附件。
const validateImportFile = (rule, value, callback) =>
form.file || rows.value.length ? callback() : callback(new Error('请上传明细表'));
const rules = {
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
customerName: [{ required: true, message: '请选择客户', trigger: 'change' }],
@@ -475,8 +511,10 @@ const rules = {
carrierContractId: [{ required: true, message: '请选择承运商合同', trigger: 'change' }],
status: [{ required: true, message: '请选择导入状态', trigger: 'change' }],
importType: [{ required: true, message: '请选择导入类型', trigger: 'change' }],
file: [{ required: true, message: '请上传明细表' , trigger: 'change' }],
file: [{ required: true, validator: validateImportFile , trigger: 'change' }],
};
// 保存草稿只落库批次与已解析的明细,仅校验后端建批次必填项,不校验附件与明细完整性。
const draftRequiredFields = ['projectId', 'contractId', 'carrierType', 'importType'];
const batches = ref([]),
details = ref([]),
rows = ref([]),
@@ -501,7 +539,14 @@ const duplicateRows = computed(() => rows.value.filter(row => row._duplicate));
const previewRows = computed(() =>
previewTab.value === 'duplicate' ? duplicateRows.value : rows.value
);
const carrierContractsLoaded = ref(false),
hasCarrierContracts = ref(null);
const carrierTypes = ['承运商', '自运', '网货平台'];
const isSelfOperated = computed(() => form.carrierType === '自运');
// 项目下没有承运商合同时只允许自运,与运单管理的承运类型收窄规则一致。
const carrierTypeOptions = computed(() =>
carrierContractsLoaded.value && hasCarrierContracts.value === false ? ['自运'] : carrierTypes
);
const carrierSelectionValue = computed(() =>
isSelfOperated.value ? form.carrierName : form.carrierContractId
);
@@ -518,46 +563,26 @@ const resetCreateForm = () => {
contracts.value = [];
carrierContracts.value = [];
carrierOptions.value = [];
carrierContractsLoaded.value = false;
hasCarrierContracts.value = null;
};
// 运单批次号由系统自动生成,整批共用一个,明细表内只读展示。
const detailColumns = [
{ prop: 'batchNo', label: '运单批次号', editor: 'input', minWidth: 150 },
{ prop: 'batchNo', label: '运单批次号', minWidth: 150 },
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 150 },
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号', editor: 'input', minWidth: 220 },
{ prop: 'driverName', label: '司机/船长', editor: 'driver', minWidth: 170 },
{ prop: 'transportType', label: '运输方式 ', editor: 'transportType', minWidth: 150 },
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', minWidth: 220 },
{ prop: 'transportType', label: '运输类型 ', editor: 'transportType', minWidth: 150 },
{ prop: 'cargoName', label: '货物名称', editor: 'cargoName', minWidth: 220 },
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', minWidth: 220 },
{ prop: 'quantity', label: '重量', editor: 'number', minWidth: 130 },
{ prop: 'departureAddress', label: '发货地址', editor: 'textarea', minWidth: 260 },
{ prop: 'departureContact', label: '发货联系人', editor: 'input', minWidth: 140 },
{ prop: 'departurePhone', label: '发货联系人电话', editor: 'input', minWidth: 160 },
{ prop: 'arrivalAddress', label: '到货地址', editor: 'textarea', minWidth: 260 },
{ prop: 'arrivalContact', label: '收货联系人', editor: 'input', minWidth: 140 },
{ prop: 'arrivalPhone', label: '收货联系人电话', editor: 'input', minWidth: 160 },
{ prop: 'startDate', label: '开始时间', editor: 'date', minWidth: 150 },
{ prop: 'endDate', label: '结束时间', editor: 'date', minWidth: 150 },
{ prop: 'unitPrice', label: '单价', editor: 'number', minWidth: 130 },
{ prop: 'freight', label: '运费', editor: 'number', minWidth: 130 },
{ prop: 'otherFeeTotal', label: '其他费用合计', editor: 'number', minWidth: 150 },
{ prop: 'freightTotal', label: '运费合计', editor: 'number', minWidth: 130 },
{ prop: 'remark', label: '备注', editor: 'textarea', minWidth: 180 },
];
const requiredDetailFields = [
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号' },
{ prop: 'driverName', label: '司机/船长' },
{ prop: 'transportType', label: '运输方式 ' },
{ prop: 'transportType', label: '运输类型 ' },
{ prop: 'cargoName', label: '货物名称' },
{ prop: 'cargoType', label: '货物类型' },
{ prop: 'departureAddress', label: '发货地址' },
{ prop: 'departureContact', label: '发货联系人' },
{ prop: 'departurePhone', label: '发货联系人电话' },
{ prop: 'arrivalAddress', label: '到货地址' },
{ prop: 'arrivalContact', label: '到货联系人' },
{ prop: 'arrivalPhone', label: '收货联系人电话' },
{ prop: 'startDate', label: '开始时间' },
{ prop: 'endDate', label: '结束时间' },
{ prop: 'unitPrice', label: '单价' },
{ prop: 'freight', label: '运费' },
];
const projectQueryParams = { approvalStatuses: 'approved,change_approved' };
const extractRecords = res => {
@@ -568,24 +593,62 @@ const extractRecords = res => {
if (Array.isArray(data?.data?.records)) return data.data.records;
return [];
};
// 批次号规则:PC + 导入日期 + 当日批次流水号(4 位),如 PC202606010001。
const batchNoPrefix = 'PC';
const batchNoSequenceLength = 4;
const generateBatchNo = async () => {
const prefix = `${batchNoPrefix}${dayjs().format('YYYYMMDD')}`;
let maxSequence = 0;
try {
const res = await api.getImportBatches({ current: 1, size: 9999 });
extractRecords(res).forEach(item => {
const no = String(item.batchNo || '').trim();
if (!no.startsWith(prefix)) return;
maxSequence = Math.max(maxSequence, Number(no.slice(prefix.length)) || 0);
});
} catch (error) {
maxSequence = 0;
}
return `${prefix}${String(maxSequence + 1).padStart(batchNoSequenceLength, '0')}`;
};
// 承运商合同的承运商固定取合同乙方,与运单管理(waybill-manage-page)保持一致。
const getCarrierContractPartyName = (contract = {}) =>
String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
contract.secondParty ||
contract.secondPartyName ||
''
).trim();
const buildCarrierContractOptions = contractRows => {
const validContracts = contractRows.filter(
item => String(item.contractCategory || '').trim() === '承运商合同'
);
const nameCounts = validContracts.reduce((result, item) => {
const name = String(item.partyA || item.p artyA Name || '').trim( );
const name = getCarrierContractP artyName(item );
if (name) result[name] = (result[name] || 0) + 1;
return result;
}, {});
return validContracts
.map(contract => {
const carrierName = String(c ontract.p artyA || contract.partyAName || '').trim( );
const carrierName = getCarrierC ontractP artyName( contract);
if (!contract.id || !carrierName) return null;
const identifier = contract.contractName || contract.contractNo || contract.id;
return {
id: contract.partyAId || contract.partyAUserId || contract.customerId || '',
id:
contract.partyBId ||
contract.partyBUserId ||
contract.contractPartyBId ||
contract.customerContractPartyBId ||
contract.secondPartyId ||
contract.secondPartyUserId ||
contract.carrierId ||
'',
carrierContractId: contract.id,
carrierName,
fullName: carrierName,
label: nameCounts[carrierName] > 1 ? `${carrierName}( ${identifier}) ` : carrierName,
value: contract.id,
};
@@ -604,7 +667,7 @@ const syncCarrierOptions = () => {
const customerContract = contracts.value.find(
item => String(item.id) === String(form.contractId)
);
const carrierName = String(customerContract?.partyB || '').trim( );
const carrierName = getCarrierContractPartyName(customerContract || {} );
if (!carrierName) {
carrierOptions.value = [];
return;
@@ -757,6 +820,7 @@ const loadBatches = async () => {
};
onMounted(() => {
if (props.standalone) loadBatches();
if (props.createPage) openCreate();
});
watch(
() => props.modelValue,
@@ -782,22 +846,50 @@ const loadDetails = async () => {
const openCreate = async () => {
resetCreateForm();
createVisible.value = true;
const [projectRes, , optionRes] = await Promise.all([
const [projectRes, , optionRes, batchNo ] = await Promise.all([
getProjectList(1, 9999, projectQueryParams),
loadEditorOptions(),
api.getImportOptions?.(),
generateBatchNo(),
]);
form.batchNo = batchNo;
projects.value = projectRes.data?.data?.records || [];
const data = optionRes?.data?.data || {};
queryCarriers.value = data.carriers || [];
creators.value = data.creators || [];
plans.value = data.plans || [];
};
const handleCreate = () => {
if (props.standalone) {
router.push({ path: '/business/waybill-import/form' });
return;
}
openCreate();
};
const closeCreate = () => {
if (props.createPage) {
router.push({ path: '/business/waybill-import' });
return;
}
createVisible.value = false;
};
const openDetail = row => {
detailQuery.batchId = row.id;
detailVisible.value = true;
loadDetails();
};
// 草稿明细已落库为草稿运单,编辑时回填到明细表,避免重新上传附件。
const loadBatchRows = async batchId => {
if (!batchId) return;
const res = await api.getImportDetails({ batchId, current: 1, size: 9999 });
rows.value = extractRecords(res).map(({ id, ...item }, index) => ({
...item,
_key: `${id || 'row'}-${index}`,
batchNo: item.batchNo || form.batchNo,
}));
rowSelection.value = [];
previewTab.value = 'all';
};
const editBatch = async row => {
await openCreate();
const snapshot = { ...row };
@@ -805,6 +897,9 @@ const editBatch = async row => {
Object.assign(form, snapshot, {
projectId: snapshot.projectId || '',
projectName: snapshot.projectName || form.projectName,
// 列表返回的是 importStatus,表单沿用 status 字段提交给后端。
status: snapshot.importStatus || 'completed',
file: null,
});
const contract = contracts.value.find(item => String(item.id) === String(snapshot.contractId));
form.contractId = contract?.id || '';
@@ -814,6 +909,7 @@ const editBatch = async row => {
const carrierValue =
form.carrierType === '自运' ? snapshot.carrierName : snapshot.carrierContractId;
if (carrierValue) carrierChange(carrierValue);
await loadBatchRows(snapshot.id);
};
const removeBatches = () =>
api.removeImportBatches(selected.value.map(item => item.id).join(',')).then(loadBatches);
@@ -829,6 +925,8 @@ const projectChange = async id => {
contracts.value = [];
carrierContracts.value = [];
carrierOptions.value = [];
carrierContractsLoaded.value = false;
hasCarrierContracts.value = null;
clearCarrier();
if (!id) return;
const [customerContractRes, carrierContractRes] = await Promise.all([
@@ -848,12 +946,14 @@ const projectChange = async id => {
contract => String(contract.contractCategory || '').trim() === '客户合同'
);
carrierContracts.value = buildCarrierContractOptions(extractRecords(carrierContractRes));
carrierContractsLoaded.value = true;
hasCarrierContracts.value = carrierContracts.value.length > 0;
if (!carrierContracts.value.length) form.carrierType = '自运';
if (contracts.value.length) {
form.contractId = contracts.value[0].id;
contractChange(form.contractId);
} else {
syncCarrierOptions();
}
syncCarrierOptions();
};
const contractChange = id => {
const item = contracts.value.find(row => String(row.id) === String(id));
@@ -918,7 +1018,7 @@ const fileChange = async (file, list) => {
};
const count = new Map();
rows.value = source.map((item, index) => {
const row = { _key: `${Date.now()}-${index}`, batchNo: '', ...item };
const row = { _key: `${Date.now()}-${index}`, ...item, batchNo: form.batchNo };
Object.entries(keyMap).forEach(([key, labels]) => {
row[key] =
labels.map(label => item[label]).find(value => String(value ?? '').trim()) ??
@@ -1029,8 +1129,7 @@ const removeSelectedRows = () => {
rows.value = rows.value.filter(row => !keys.has(row._key));
rowSelection.value = [];
};
const firstNotEmpty = (...values) =>
values.find(value => String(value ?? '').trim()) ?? '';
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
const normalizeOptionalImportNumber = (value, emptySentinels = []) => {
if (!String(value ?? '').trim()) return null;
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
@@ -1052,9 +1151,25 @@ const normalizeImportRow = row => ({
row['*运输方式']
),
});
const buildImportPayload = () => ({ ...form, rows: rows.value.map(normalizeImportRow) });
const saveDraft = () => api.saveImportDraft(buildImportPayload()) ;
const buildImportPayload = (status = form.status) => {
const { file, ...batch } = form ;
return { ...batch, status, rows: rows.value.map(normalizeImportRow) };
};
// 独立页面下返回批次列表路由,弹窗形态下关闭新建面板并刷新列表。
const backToList = () => {
closeCreate();
if (!props.createPage) loadBatches();
};
// 保存草稿:批次与已解析明细一并入库,成功后返回批次列表。
const saveDraft = async () => {
await formRef.value?.validateField(draftRequiredFields);
await api.saveImportDraft(buildImportPayload('draft'));
ElMessage.success('保存草稿成功');
backToList();
};
const confirmImport = async () => {
// 导入状态选择草稿时不生成正式运单,等同于保存草稿。
if (form.status === 'draft') return saveDraft();
await formRef.value?.validate();
if (!validateImportRows()) return;
const count = rows.value.filter(row => row._duplicate).length;
@@ -1063,8 +1178,7 @@ const confirmImport = async () => {
await ElMessageBox.confirm('确认后将同时生成运单、应收应付明细、计结算单,是否继续?', '提示');
await api.confirmImport(buildImportPayload());
ElMessage.success('导入成功');
createVisible.value = false ;
loadBatches();
backToList() ;
};
</script>
@@ -1133,5 +1247,41 @@ const confirmImport = async () => {
: deep ( . el - table . el - date - editor ) {
width : 100 % ;
}
& _ _footer {
position : fixed ;
z - index : 20 ;
right : 0 ;
bottom : 0 ;
left : 230 px ;
display : flex ;
align - items : center ;
justify - content : flex - end ;
min - height : 64 px ;
box - sizing : border - box ;
padding : 12 px 24 px ;
background : # fff ;
border - top : 1 px solid # eff1f7 ;
box - shadow : 0 - 2 px 8 px rgba ( 0 , 0 , 0 , 0.06 ) ;
// 统一 12px 间距,与全站底部操作栏(去 gap,由相邻按钮 margin 提供)一致
. el - button + . el - button {
margin - left : 12 px ;
}
}
& -- page {
min - height : calc ( 100 vh - 120 px ) ;
padding : 12 px 24 px 96 px ;
background : # fff ;
}
}
: global ( . avue -- collapse . waybill - import - create _ _footer ) {
left : 60 px ;
}
: global ( . avue - layout -- horizontal . waybill - import - create _ _footer ) {
left : 0 ;
}
< / style >