调整结算模块

This commit is contained in:
2026-09-07 19:37:27 +08:00
parent 0d070d4b34
commit a7315b4491
21 changed files with 1876 additions and 635 deletions
@@ -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="openCreate">新建导入</el-button
<el-button type="primary" @click="handleCreate">新建导入</el-button
><el-button type="danger" plain :disabled="!selected.length" @click="removeBatches"
>批量删除</el-button
>
@@ -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)"
>编辑</el-link
><el-link type="danger" @click="removeBatch(row)">删除</el-link></template
>
</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></el-form-item
>
</el-form>
<div class="waybill-import-create__form-actions">
<el-button @click="createVisible = false">关闭</el-button
<div v-if="!createPage" class="waybill-import-create__form-actions">
<el-button @click="closeCreate">关闭</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(false),
const createVisible = ref(props.createPage),
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.partyAName || '').trim();
const name = getCarrierContractPartyName(item);
if (name) result[name] = (result[name] || 0) + 1;
return result;
}, {});
return validContracts
.map(contract => {
const carrierName = String(contract.partyA || contract.partyAName || '').trim();
const carrierName = getCarrierContractPartyName(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: 230px;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 64px;
box-sizing: border-box;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
// 统一 12px 间距,与全站底部操作栏(去 gap,由相邻按钮 margin 提供)一致
.el-button + .el-button {
margin-left: 12px;
}
}
&--page {
min-height: calc(100vh - 120px);
padding: 12px 24px 96px;
background: #fff;
}
}
:global(.avue--collapse .waybill-import-create__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .waybill-import-create__footer) {
left: 0;
}
</style>