✨ 完善运单导入编辑回填、分批校验与运费录入
导入编辑进入独立页面并回填数据,确认导入按每批100条校验后统一导出失败明细,完整录入运费信息按数量单位联动单价单位。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -202,7 +202,7 @@
|
||||
<section-card class="waybill-search">
|
||||
<el-form :inline="true" :model="detailQuery"
|
||||
>
|
||||
<el-form-item label="车牌号/船号"
|
||||
<el-form-item label="车牌号/船班号/船次号/班次号"
|
||||
><el-input v-model="detailQuery.vehicleNo" clearable /></el-form-item
|
||||
><el-form-item label="司机/船长姓名"
|
||||
><el-input v-model="detailQuery.driverName" clearable placeholder="请输入" /></el-form-item
|
||||
@@ -219,12 +219,14 @@
|
||||
prop="loadingIdentifier"
|
||||
label="配载标识号"
|
||||
min-width="130"
|
||||
/><el-table-column
|
||||
>
|
||||
<template #default="{ row }">{{ row.loadingIdentifier || row.loadingNo || '' }}</template>
|
||||
</el-table-column><el-table-column
|
||||
prop="vehicleNo"
|
||||
label="车牌号/航班号/船号/班列号"
|
||||
label="车牌号/船班号/船次号/班次号"
|
||||
min-width="190"
|
||||
/><el-table-column prop="transportType" label="运输方式" width="110">
|
||||
<template #default="{ row }">{{ transportTypeLabel(row.transportType) }}</template>
|
||||
<template #default="{ row }">{{ transportTypeLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="driverName"
|
||||
@@ -334,6 +336,7 @@
|
||||
label-width="auto"
|
||||
class="archive-form"
|
||||
>
|
||||
<div class="dialog-section-title">项目信息</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="项目" prop="projectId">
|
||||
@@ -444,16 +447,21 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="dialog-section-title waybill-import-create__import-title">导入运单明细</div>
|
||||
<p v-if="!projectInfoReady" class="waybill-import-create__import-hint">
|
||||
请先选择项目、客户合同和承运信息,再导入运单明细
|
||||
</p>
|
||||
<el-form-item label="运单明细表" prop="file"
|
||||
><el-upload
|
||||
action="#"
|
||||
accept=".xls,.xlsx"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:disabled="!projectInfoReady"
|
||||
:file-list="files"
|
||||
:on-change="fileChange"
|
||||
:on-remove="fileRemove"
|
||||
><el-button type="primary">添加附件</el-button></el-upload
|
||||
><el-button type="primary" :disabled="!projectInfoReady">导入</el-button></el-upload
|
||||
><span class="waybill-import-create__file-tip">请上传运单明细表,仅支持 excel 格式;日期支持 2026-08-02、2026-8-2、2026/8/2 等写法</span
|
||||
><el-link type="primary" @click="downloadTemplate">下载模板</el-link></el-form-item
|
||||
>
|
||||
@@ -479,6 +487,7 @@
|
||||
:data="previewRows"
|
||||
border
|
||||
max-height="440"
|
||||
:row-class-name="({ row }) => (row._duplicate ? 'is-duplicate-row' : '')"
|
||||
@selection-change="rowSelection = $event"
|
||||
><el-table-column type="selection" width="55" /><el-table-column
|
||||
type="index"
|
||||
@@ -564,14 +573,18 @@
|
||||
/><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"
|
||||
><el-table-column label="操作" width="220" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><template v-if="row._editing"
|
||||
><el-link type="primary" @click="saveRow(row)">保存</el-link
|
||||
><el-link type="primary" @click="cancelRow(row)">取消</el-link></template
|
||||
><template v-else
|
||||
><el-link type="primary" @click="editRow(row)">编辑</el-link
|
||||
><el-link type="danger" @click="deleteRow(row)">删除</el-link></template
|
||||
><div class="waybill-import-create__row-actions"
|
||||
><template v-if="row._editing"
|
||||
><el-link type="primary" @click="saveRow(row)">保存</el-link
|
||||
><el-link type="primary" @click="cancelRow(row)">取消</el-link></template
|
||||
><template v-else
|
||||
><el-link v-if="row._duplicate" type="primary" @click="confirmDuplicateRow(row)"
|
||||
>确认</el-link
|
||||
><el-link type="primary" @click="editRow(row)">编辑</el-link
|
||||
><el-link type="danger" @click="deleteRow(row)">删除</el-link></template
|
||||
></div
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
@@ -579,6 +592,7 @@
|
||||
</section>
|
||||
<div v-if="createPage" class="waybill-import-create__footer">
|
||||
<el-button @click="closeCreate">取消</el-button>
|
||||
<el-button @click="saveDraft">保存草稿</el-button>
|
||||
<el-button type="primary" @click="confirmImport">确认导入</el-button>
|
||||
</div>
|
||||
</component>
|
||||
@@ -586,7 +600,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
|
||||
@@ -601,6 +615,7 @@ import SectionCard from '@/components/section-card/main.vue';
|
||||
|
||||
const props = defineProps({ modelValue: Boolean, standalone: Boolean, createPage: Boolean });
|
||||
const emit = defineEmits(['update:modelValue', 'closed']);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -656,10 +671,10 @@ const createDefaultForm = () => ({
|
||||
file: null,
|
||||
});
|
||||
const form = reactive(createDefaultForm());
|
||||
// 批量导入状态仅保留草稿与导入完成两种,与后端 importStatus 取值一致。
|
||||
// 导入状态与运单业务状态一致:进行中、已完成。
|
||||
const importStatusOptions = [
|
||||
{ label: '进行中', value: 'draft' },
|
||||
{ label: '导入完成', value: 'completed' },
|
||||
{ label: '进行中', value: 'running' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
];
|
||||
// 编辑草稿时明细已入库,此时不再强制重新上传附件。
|
||||
const validateImportFile = (rule, value, callback) =>
|
||||
@@ -705,6 +720,21 @@ const carrierContractsLoaded = ref(false),
|
||||
hasCarrierContracts = ref(null);
|
||||
const carrierTypes = ['承运商', '自运'];
|
||||
const isSelfOperated = computed(() => form.carrierType === '自运');
|
||||
const projectInfoReady = computed(() => {
|
||||
if (
|
||||
!form.projectId ||
|
||||
!String(form.customerName || '').trim() ||
|
||||
!form.contractId ||
|
||||
!form.carrierType ||
|
||||
!form.status ||
|
||||
!form.importType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isSelfOperated.value
|
||||
? Boolean(String(form.carrierName || '').trim())
|
||||
: Boolean(form.carrierContractId);
|
||||
});
|
||||
// 项目下没有承运商合同时只允许自运,与运单管理的承运类型收窄规则一致。
|
||||
const carrierTypeOptions = computed(() =>
|
||||
carrierContractsLoaded.value && hasCarrierContracts.value === false ? ['自运'] : carrierTypes
|
||||
@@ -733,7 +763,7 @@ const detailColumns = [
|
||||
{ prop: 'batchNo', label: '运单批次号', minWidth: 150 },
|
||||
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 150 },
|
||||
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号', editor: 'input', minWidth: 220 },
|
||||
{ prop: 'vehicleNo', label: '车牌号/船班号/船次号/班次号', editor: 'input', minWidth: 220 },
|
||||
{ prop: 'transportType', label: '运输方式', editor: 'transportType', minWidth: 150 },
|
||||
{ prop: 'driverName', label: '司机/船长姓名', editor: 'driver', minWidth: 170 },
|
||||
{ prop: 'driverPhone', label: '司机/船长手机号', editor: 'input', minWidth: 150 },
|
||||
@@ -763,7 +793,7 @@ const detailColumns = [
|
||||
{ prop: 'waybillIdentifier', label: '同一运单标识号', editor: 'input', minWidth: 150 },
|
||||
];
|
||||
const requiredDetailFields = [
|
||||
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号' },
|
||||
{ prop: 'vehicleNo', label: '车牌号/船班号/船次号/班次号' },
|
||||
{ prop: 'transportType', label: '运输方式' },
|
||||
{ prop: 'departureAddress', label: '发货地址' },
|
||||
{ prop: 'arrivalAddress', label: '到货地址' },
|
||||
@@ -976,33 +1006,78 @@ const loadCommonCargoOptions = async row => {
|
||||
commonCargoLoadingMap.value = { ...commonCargoLoadingMap.value, [key]: false };
|
||||
}
|
||||
};
|
||||
const TRANSPORT_TYPE_DEFAULTS = [
|
||||
{ label: '公路运输', value: 'road', dictKey: 'road', dictValue: '公路运输' },
|
||||
{ label: '铁路运输', value: 'railway', dictKey: 'railway', dictValue: '铁路运输' },
|
||||
{ label: '水路运输', value: 'river', dictKey: 'river', dictValue: '水路运输' },
|
||||
{ label: '航空运输', value: 'air', dictKey: 'air', dictValue: '航空运输' },
|
||||
];
|
||||
const readDictionaryList = res => {
|
||||
const payload = res?.data?.data ?? res?.data ?? res;
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload?.records)) return payload.records;
|
||||
if (Array.isArray(payload?.data)) return payload.data;
|
||||
return [];
|
||||
};
|
||||
const normalizeTransportTypeOptions = list => {
|
||||
const options = [];
|
||||
const append = item => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
const dictKey = String(item.dictKey ?? item.value ?? '').trim();
|
||||
const dictValue = String(item.dictValue ?? item.label ?? '').trim();
|
||||
if (dictKey === '-1') {
|
||||
(item.children || []).forEach(append);
|
||||
return;
|
||||
}
|
||||
if (dictKey || dictValue) {
|
||||
options.push({
|
||||
label: dictValue || dictKey,
|
||||
value: dictKey || dictValue,
|
||||
dictKey,
|
||||
dictValue,
|
||||
});
|
||||
}
|
||||
(item.children || []).forEach(append);
|
||||
};
|
||||
(list || []).forEach(append);
|
||||
return options.length ? options : TRANSPORT_TYPE_DEFAULTS;
|
||||
};
|
||||
const applyTransportTypeOptions = res => {
|
||||
transportTypeOptions.value = normalizeTransportTypeOptions(readDictionaryList(res));
|
||||
};
|
||||
const loadEditorOptions = async () => {
|
||||
const [dictRes, cargoTypeRes, driverRes] = await Promise.all([
|
||||
getDictionary({ code: 'transport_type' }),
|
||||
getCargoTypeList(1, 9999),
|
||||
getDriverList(1, 9999, {}),
|
||||
]);
|
||||
transportTypeOptions.value = extractRecords(dictRes).map(item => ({
|
||||
label: item.dictValue || item.label,
|
||||
value: item.dictKey || item.value,
|
||||
}));
|
||||
applyTransportTypeOptions(dictRes);
|
||||
cargoTypeOptions.value = buildCargoTypeTree(extractRecords(cargoTypeRes));
|
||||
cargoTypeFlatOptions.value = flattenCargoTypeOptions(cargoTypeOptions.value);
|
||||
driverOptions.value = extractRecords(driverRes);
|
||||
};
|
||||
const ensureTransportTypeOptions = async () => {
|
||||
if (transportTypeOptions.value.length) return;
|
||||
const dictRes = await getDictionary({ code: 'transport_type' });
|
||||
transportTypeOptions.value = extractRecords(dictRes).map(item => ({
|
||||
label: item.dictValue || item.label,
|
||||
value: item.dictKey || item.value,
|
||||
}));
|
||||
try {
|
||||
applyTransportTypeOptions(await getDictionary({ code: 'transport_type' }));
|
||||
} catch (error) {
|
||||
transportTypeOptions.value = TRANSPORT_TYPE_DEFAULTS;
|
||||
}
|
||||
};
|
||||
const transportTypeLabel = value => {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
return (
|
||||
transportTypeOptions.value.find(item => String(item.value) === String(value))?.label || value
|
||||
const source = value && typeof value === 'object' ? value : null;
|
||||
const stored = source ? source.transportType : value;
|
||||
const named = String(source?.transportTypeName || '').trim();
|
||||
const text = String(stored ?? '').trim();
|
||||
if (named && named !== text) return named;
|
||||
if (!text && !named) return '-';
|
||||
const target = (named || text).toLowerCase();
|
||||
const matched = transportTypeOptions.value.find(item =>
|
||||
[item.value, item.dictKey, item.label, item.dictValue].some(
|
||||
candidate => String(candidate ?? '').trim().toLowerCase() === target
|
||||
)
|
||||
);
|
||||
return matched?.dictValue || matched?.label || named || text || '-';
|
||||
};
|
||||
const loadBatches = async () => {
|
||||
const res = await api.getImportBatches({ ...query, current: page.current, size: page.size });
|
||||
@@ -1011,7 +1086,7 @@ const loadBatches = async () => {
|
||||
};
|
||||
onMounted(() => {
|
||||
if (props.standalone) loadBatches();
|
||||
if (props.createPage) openCreate();
|
||||
if (props.createPage) openCreatePage();
|
||||
});
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -1034,16 +1109,18 @@ const loadDetails = async () => {
|
||||
details.value = res.data?.data?.records || [];
|
||||
detailPage.total = res.data?.data?.total || 0;
|
||||
};
|
||||
const openCreate = async () => {
|
||||
const openCreate = () => prepareCreateForm(true);
|
||||
const prepareCreateForm = async generateCode => {
|
||||
resetCreateForm();
|
||||
createVisible.value = true;
|
||||
const [projectRes, , optionRes, batchNo] = await Promise.all([
|
||||
const requests = [
|
||||
getProjectList(1, 9999, projectQueryParams),
|
||||
loadEditorOptions(),
|
||||
api.getImportOptions?.(),
|
||||
generateBatchNo(),
|
||||
]);
|
||||
form.batchNo = batchNo;
|
||||
];
|
||||
if (generateCode) requests.push(generateBatchNo());
|
||||
const [projectRes, , optionRes, batchNo] = await Promise.all(requests);
|
||||
if (generateCode) form.batchNo = batchNo || '';
|
||||
projects.value = projectRes.data?.data?.records || [];
|
||||
const data = optionRes?.data?.data || {};
|
||||
queryCarriers.value = data.carriers || [];
|
||||
@@ -1082,27 +1159,74 @@ const loadBatchRows = async batchId => {
|
||||
_key: `${id || 'row'}-${index}`,
|
||||
batchNo: item.batchNo || form.batchNo,
|
||||
}));
|
||||
refreshDuplicateFlags();
|
||||
rowSelection.value = [];
|
||||
previewTab.value = 'all';
|
||||
};
|
||||
const editBatch = async row => {
|
||||
await openCreate();
|
||||
const editBatch = row => {
|
||||
router.push({
|
||||
path: '/business/waybill-import/form',
|
||||
query: { id: row.id, batchNo: row.batchNo || '' },
|
||||
});
|
||||
};
|
||||
const openCreatePage = async () => {
|
||||
const batchId = route.query.id;
|
||||
if (!batchId) {
|
||||
await openCreate();
|
||||
return;
|
||||
}
|
||||
const res = await api.getImportBatches({
|
||||
id: batchId,
|
||||
batchNo: route.query.batchNo || '',
|
||||
current: 1,
|
||||
size: 20,
|
||||
});
|
||||
const batch = (res.data?.data?.records || []).find(item => String(item.id) === String(batchId));
|
||||
if (!batch) {
|
||||
ElMessage.warning('未找到导入批次');
|
||||
router.push({ path: '/business/waybill-import' });
|
||||
return;
|
||||
}
|
||||
await fillEditForm(batch);
|
||||
};
|
||||
const fillEditForm = async row => {
|
||||
await prepareCreateForm(false);
|
||||
const snapshot = { ...row };
|
||||
const savedCarrierId = String(snapshot.carrierIds || snapshot.carrierId || '')
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.find(Boolean);
|
||||
if (snapshot.projectId) await projectChange(snapshot.projectId);
|
||||
const project = projects.value.find(item => String(item.id) === String(snapshot.projectId));
|
||||
Object.assign(form, snapshot, {
|
||||
projectId: snapshot.projectId || '',
|
||||
id: snapshot.id || '',
|
||||
batchNo: snapshot.batchNo || '',
|
||||
projectId: project?.id || snapshot.projectId || '',
|
||||
projectName: snapshot.projectName || form.projectName,
|
||||
// 列表返回的是 importStatus,表单沿用 status 字段提交给后端。
|
||||
status: snapshot.importStatus || 'completed',
|
||||
status:
|
||||
snapshot.importStatus === 'running' || snapshot.importStatus === 'completed'
|
||||
? snapshot.importStatus
|
||||
: 'completed',
|
||||
file: null,
|
||||
carrierIds: [],
|
||||
});
|
||||
const plan = plans.value.find(item => String(item.id) === String(snapshot.planId));
|
||||
if (plan) form.planId = plan.id;
|
||||
const contract = contracts.value.find(item => String(item.id) === String(snapshot.contractId));
|
||||
form.contractId = contract?.id || '';
|
||||
form.carrierType = snapshot.carrierType || '承运商';
|
||||
contractChange(form.contractId);
|
||||
syncCarrierOptions();
|
||||
const carrierOption =
|
||||
form.carrierType === '自运'
|
||||
? null
|
||||
: carrierOptions.value.find(item => String(item.id) === String(savedCarrierId || '')) ||
|
||||
carrierOptions.value.find(item => item.carrierName === snapshot.carrierName);
|
||||
const carrierValue =
|
||||
form.carrierType === '自运' ? snapshot.carrierName : snapshot.carrierContractId;
|
||||
form.carrierType === '自运'
|
||||
? snapshot.carrierName
|
||||
: snapshot.carrierContractId || carrierOption?.value;
|
||||
if (carrierValue) carrierChange(carrierValue);
|
||||
await loadBatchRows(snapshot.id);
|
||||
};
|
||||
@@ -1170,6 +1294,12 @@ const carrierChange = value => {
|
||||
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
|
||||
};
|
||||
const fileChange = async (file, list) => {
|
||||
if (!projectInfoReady.value) {
|
||||
ElMessage.warning('请先选择项目、客户合同和承运信息,再导入运单明细');
|
||||
files.value = [];
|
||||
form.file = null;
|
||||
return;
|
||||
}
|
||||
if (file.raw?.size > 50 * 1024 * 1024) {
|
||||
ElMessage.warning('单个文件大小不能超过50M');
|
||||
files.value = [];
|
||||
@@ -1190,7 +1320,7 @@ const fileChange = async (file, list) => {
|
||||
const keyMap = {
|
||||
originalNo: ['原始单号'],
|
||||
loadingIdentifier: ['配载标识号'],
|
||||
vehicleNo: ['车牌号/航班号/船号/班列号'],
|
||||
vehicleNo: ['车牌号/船班号/船次号/班次号', '车牌号/航班号/船号/班列号'],
|
||||
transportType: ['运输方式', '运输类型'],
|
||||
driverName: ['司机/船长姓名', '司机/船长'],
|
||||
driverPhone: ['司机/船长手机号'],
|
||||
@@ -1227,7 +1357,6 @@ const fileChange = async (file, list) => {
|
||||
const text = String(value ?? '').trim();
|
||||
return text.slice(0, 10);
|
||||
};
|
||||
const count = new Map();
|
||||
// 先解析数据,但不填充到 rows.value
|
||||
const parsedRows = source.map((item, index) => {
|
||||
const row = { _key: `${Date.now()}-${index}`, ...item, batchNo: form.batchNo };
|
||||
@@ -1241,14 +1370,9 @@ const fileChange = async (file, list) => {
|
||||
row.actualEndDate = normalizeImportDate(row.actualEndDate);
|
||||
row.planStartDate = normalizeImportDate(row.planStartDate);
|
||||
row.planEndDate = normalizeImportDate(row.planEndDate);
|
||||
const duplicateKey = JSON.stringify(Object.keys(keyMap).map(key => row[key]));
|
||||
count.set(duplicateKey, (count.get(duplicateKey) || 0) + 1);
|
||||
row._duplicateKey = duplicateKey;
|
||||
return row;
|
||||
});
|
||||
parsedRows.forEach(row => {
|
||||
row._duplicate = count.get(row._duplicateKey) > 1;
|
||||
});
|
||||
refreshDuplicateFlags(parsedRows);
|
||||
|
||||
// 调用后端校验接口,校验通过才填充表格
|
||||
const isValid = await performValidation(parsedRows);
|
||||
@@ -1271,8 +1395,39 @@ const fileRemove = () => {
|
||||
/** 运单批量导入每次请求最多提交行数,避免网关 body 超限。 */
|
||||
const IMPORT_BATCH_SIZE = 100;
|
||||
|
||||
const importFieldText = value => String(value ?? '').trim();
|
||||
|
||||
/** 同一运单批次号下,全部导入字段一致时标记为疑似重复。已在线确认的行不再标为重复。 */
|
||||
const refreshDuplicateFlags = (list = rows.value) => {
|
||||
const groups = new Map();
|
||||
list.forEach(row => {
|
||||
const signature = detailColumns.map(column => importFieldText(row[column.prop])).join('\u0001');
|
||||
const batchNo = importFieldText(row.batchNo || form.batchNo);
|
||||
if (row._duplicateConfirmed && row._duplicateSignature !== signature) {
|
||||
row._duplicateConfirmed = false;
|
||||
}
|
||||
row._duplicateSignature = signature;
|
||||
const groupKey = `${batchNo}\u0002${signature}`;
|
||||
const group = groups.get(groupKey) || [];
|
||||
group.push(row);
|
||||
groups.set(groupKey, group);
|
||||
});
|
||||
groups.forEach(group => {
|
||||
const duplicated = group.length > 1;
|
||||
group.forEach(row => {
|
||||
row._duplicate = duplicated && !row._duplicateConfirmed;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDuplicateRow = row => {
|
||||
row._duplicateConfirmed = true;
|
||||
refreshDuplicateFlags();
|
||||
ElMessage.success('已确认,该行将按正常数据导入');
|
||||
};
|
||||
|
||||
const stripInternalRowFields = row => {
|
||||
const { _key, _duplicate, _duplicateKey, _editing, _editSnapshot, ...data } = row;
|
||||
const { _key, _duplicate, _duplicateKey, _duplicateConfirmed, _duplicateSignature, _editing, _editSnapshot, ...data } = row;
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1334,6 +1489,37 @@ const downloadImportFailures = async failureRows => {
|
||||
downloadXls(blob, `运单导入失败明细_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`);
|
||||
};
|
||||
|
||||
/** 按每批 100 条请求校验,全部批次结束后再汇总失败行。 */
|
||||
const collectImportValidationErrors = async rowList => {
|
||||
const errorMap = new Map();
|
||||
for (const chunk of chunkImportRows(rowList)) {
|
||||
const response = await api.validateImport(buildImportPayload(form.status, chunk.rows));
|
||||
const result = response?.data?.data ?? response?.data ?? response;
|
||||
if (result?.success === false || (Array.isArray(result?.errors) && result.errors.length)) {
|
||||
(result.errors || []).forEach(error => {
|
||||
const localIndex = Number(error.rowIndex);
|
||||
if (Number.isNaN(localIndex) || localIndex < 0 || localIndex >= chunk.globalIndexes.length) {
|
||||
return;
|
||||
}
|
||||
errorMap.set(chunk.globalIndexes[localIndex], error.errorMessage || '校验失败');
|
||||
});
|
||||
}
|
||||
}
|
||||
return errorMap;
|
||||
};
|
||||
|
||||
const exportCollectedImportFailures = async (rowList, errorMap, exportAll = false) => {
|
||||
const indexedRows = exportAll
|
||||
? rowList.map((row, globalIndex) => [globalIndex, errorMap.get(globalIndex) || ''])
|
||||
: [...errorMap.entries()].sort((left, right) => left[0] - right[0]);
|
||||
const failureRows = indexedRows.map(([globalIndex, errorMessage]) => ({
|
||||
...stripInternalRowFields(rowList[globalIndex]),
|
||||
errorMessage,
|
||||
}));
|
||||
await downloadImportFailures(failureRows);
|
||||
ElMessage.warning('数据校验失败,已自动下载错误明细表,请修正后重新上传');
|
||||
};
|
||||
|
||||
const performValidation = async parsedRows => {
|
||||
if (!parsedRows || !parsedRows.length) return false;
|
||||
|
||||
@@ -1361,15 +1547,7 @@ const performValidation = async parsedRows => {
|
||||
}
|
||||
|
||||
if (errorMap.size) {
|
||||
const failureRows = [];
|
||||
errorMap.forEach((errorMessage, globalIndex) => {
|
||||
failureRows.push({
|
||||
...stripInternalRowFields(parsedRows[globalIndex]),
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
await downloadImportFailures(failureRows);
|
||||
ElMessage.warning('数据校验失败,已自动下载错误明细表,请修正后重新上传');
|
||||
await exportCollectedImportFailures(parsedRows, errorMap);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1381,7 +1559,8 @@ const performValidation = async parsedRows => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const formatCell = (row, column) => row[column.prop] || '';
|
||||
const formatCell = (row, column) =>
|
||||
column.prop === 'transportType' ? transportTypeLabel(row) : row[column.prop] || '';
|
||||
const updateEditorValue = (column, row, value) => {
|
||||
if (column.editor === 'number') {
|
||||
const [integer = '', decimal = ''] = String(value || '')
|
||||
@@ -1456,6 +1635,7 @@ const editRow = row => {
|
||||
const saveRow = row => {
|
||||
row._editing = false;
|
||||
delete row._editSnapshot;
|
||||
refreshDuplicateFlags();
|
||||
};
|
||||
const cancelRow = row => {
|
||||
if (row._editSnapshot) Object.assign(row, row._editSnapshot);
|
||||
@@ -1464,11 +1644,13 @@ const cancelRow = row => {
|
||||
};
|
||||
const deleteRow = row => {
|
||||
rows.value = rows.value.filter(item => item !== row);
|
||||
refreshDuplicateFlags();
|
||||
};
|
||||
const removeSelectedRows = () => {
|
||||
const keys = new Set(rowSelection.value.map(row => row._key));
|
||||
rows.value = rows.value.filter(row => !keys.has(row._key));
|
||||
rowSelection.value = [];
|
||||
refreshDuplicateFlags();
|
||||
};
|
||||
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
|
||||
const normalizeOptionalImportNumber = (value, emptySentinels = []) => {
|
||||
@@ -1534,17 +1716,27 @@ const saveDraft = async () => {
|
||||
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;
|
||||
if (count) await ElMessageBox.confirm(`当前导入有${count}条重复数据,是否确认导入?`, '提示');
|
||||
if (form.importType === 'settlement')
|
||||
if (count) await ElMessageBox.confirm(`当前导入有${count}条疑似重复数据,是否确认导入?`, '提示');
|
||||
if (form.importType === 'settlement' && form.status === 'completed')
|
||||
await ElMessageBox.confirm('确认后将同时生成运单、应收应付明细、计结算单,是否继续?', '提示');
|
||||
|
||||
const chunks = chunkImportRows(rows.value);
|
||||
// 仅草稿转确认时复用原批次 id;过程中不回写 form.id,避免失败重试误覆盖已完成批次。
|
||||
let errorMap;
|
||||
try {
|
||||
errorMap = await collectImportValidationErrors(rows.value);
|
||||
} catch (error) {
|
||||
console.error('校验失败:', error);
|
||||
ElMessage.error(error?.message || '校验接口调用失败');
|
||||
return;
|
||||
}
|
||||
if (errorMap.size) {
|
||||
await exportCollectedImportFailures(rows.value, errorMap, true);
|
||||
return;
|
||||
}
|
||||
// 校验全部通过后才分批落库,避免前面批次已入库、后面批次校验失败。
|
||||
const draftBatchId = form.id || '';
|
||||
let batchId = '';
|
||||
try {
|
||||
@@ -1660,6 +1852,15 @@ const confirmImport = async () => {
|
||||
&__detail-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
&__import-title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
&__import-hint {
|
||||
margin: 0 0 12px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
&__form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -1672,6 +1873,14 @@ const confirmImport = async () => {
|
||||
gap: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&__row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
:deep(.is-duplicate-row > td.el-table__cell) {
|
||||
background: #fff7e8;
|
||||
}
|
||||
&__file-tip {
|
||||
margin: 0 24px;
|
||||
color: #606266;
|
||||
|
||||
Reference in New Issue
Block a user