Files
tms-erp-web/src/views/settlement/components/settlement-import-page.vue
T
b2894lxlx 6f2383a7e5 ✨ 结算付款体验优化与合同/运单导入校验
付款申请汇票筛选与提示去重、结算菜单拆分与金蝶同步弹框、应收应付详情高度、合同计费与半月结调整,以及导入运单批量删除校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 05:12:26 +08:00

1119 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="settlement-import-create" :class="{ 'is-readonly': readonly }">
<section class="settlement-import-create__form-panel">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
class="settlement-import-create__form"
>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="项目" prop="projectId">
<el-select
v-model="form.projectId"
filterable
clearable
:disabled="readonly"
placeholder="请选择项目"
@change="onProjectChange"
>
<el-option
v-for="item in projects"
: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="settlementType">
<el-select
v-model="form.settlementType"
:disabled="readonly"
clearable
placeholder="请选择结算类型"
style="width: 100%"
@change="onSettlementTypeChange"
>
<el-option label="应收" value="receivable" />
<el-option label="应付" value="payable" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结算单" prop="settlementBillType">
<el-select
v-model="form.settlementBillType"
:disabled="readonly || form.settlementType !== 'payable'"
placeholder="请选择结算单"
style="width: 100%"
>
<el-option label="正式结算单" value="formal" />
<el-option
v-if="form.settlementType === 'payable'"
label="预付结算单"
value="pre"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户合同" prop="customerContractId">
<el-select
v-model="form.customerContractId"
filterable
clearable
:disabled="readonly || !form.projectId"
style="width: 100%"
:placeholder="form.projectId ? '请选择客户合同' : '请先选择项目'"
@change="onCustomerContractChange"
>
<el-option
v-for="item in filteredCustomerContracts"
:key="item.id"
:label="formatContractLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户">
<el-input v-model="form.customerName" disabled placeholder="选择客户合同后自动填充" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="承运商合同" prop="carrierContractId">
<el-select
v-model="form.carrierContractId"
filterable
clearable
:disabled="readonly || !form.projectId"
style="width: 100%"
:placeholder="form.projectId ? '请选择承运商合同' : '请先选择项目'"
@change="onCarrierContractChange"
>
<el-option
v-for="item in filteredCarrierContracts"
:key="item.id"
:label="formatContractLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="承运商">
<el-input v-model="form.carrierName" disabled placeholder="选择承运商合同后自动填充" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item prop="waybillMode" class="settlement-import-create__waybill-mode">
<template #label>
<span>运单类型</span>
<el-tooltip placement="top">
<template #content>
<div>
新运单:提交时同步生成运单、应收/应付明细、预/正式结算单;费用项目与金额按导入单据
</div>
<div>
历史运单:按 项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称
匹配已有运单(命中 1 条自动绑定,多条需手工指定运单号);提交时生成应收/应付明细与预/正式结算单,费用按导入单据,不新建运单
</div>
</template>
<el-icon class="settlement-import-create__label-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-radio-group v-model="form.waybillMode" :disabled="readonly">
<el-radio value="new">新运单</el-radio>
<el-radio value="history">历史运单</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="结算单上传" prop="file">
<div class="settlement-import-create__upload">
<el-upload
ref="uploadRef"
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:limit="1"
:disabled="readonly || !formReady"
:file-list="files"
:on-change="onFileChange"
:on-exceed="onFileExceed"
:on-remove="onFileRemove"
>
<el-button type="primary" :disabled="readonly || !formReady">上传 Excel</el-button>
</el-upload>
<el-link type="primary" :underline="false" @click="downloadTemplate">
下载模板
</el-link>
<span class="settlement-import-create__upload-tip">仅支持 .xls / .xlsx</span>
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="readonly"
placeholder="请输入备注"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section>
<section v-if="rows.length" class="settlement-import-create__detail-panel">
<div class="settlement-import-create__detail-head">
<div class="dialog-section-title">导入数据明细</div>
<el-button
v-if="!readonly"
type="danger"
plain
:disabled="!rowSelection.length"
@click="removeSelectedRows"
>
批量删除
</el-button>
</div>
<el-tabs v-model="previewTab">
<el-tab-pane :label="`运单数(${rows.length})`" name="all" />
<el-tab-pane :label="`疑似重复(${duplicateRows.length})`" name="duplicate" />
</el-tabs>
<el-table
:data="previewRows"
border
max-height="480"
class="settlement-import-create__table"
:row-class-name="({ row }) => (row._duplicate ? 'is-duplicate-row' : '')"
@selection-change="rowSelection = $event"
>
<el-table-column v-if="!readonly" type="selection" width="48" fixed="left" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in detailColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth || 120"
show-overflow-tooltip
>
<template #default="{ row }">
<template v-if="row._editing && column.editor && !column.readonly">
<el-date-picker
v-if="column.editor === 'date'"
v-model="row[column.prop]"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
<el-input
v-else-if="column.editor === 'number'"
:model-value="resolveCellValue(row, column)"
inputmode="decimal"
:placeholder="`请输入${column.label}`"
@input="value => updateNumberCell(row, column, value)"
/>
<el-input
v-else
v-model="row[column.prop]"
:placeholder="`请输入${column.label}`"
/>
</template>
<span v-else>{{ formatCell(row, column) }}</span>
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="settlement-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>
</section>
<div class="settlement-import-create__footer">
<el-button @click="goBack">取消</el-button>
<el-button v-if="!readonly" :loading="draftLoading" @click="saveDraft">保存草稿</el-button>
<el-button
v-if="!readonly"
type="primary"
:loading="confirmLoading"
@click="confirmImport"
>
确认导入
</el-button>
</div>
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { QuestionFilled } from '@element-plus/icons-vue';
import dayjs from 'dayjs';
import * as api from '@/api/settlement/settlementImport';
import { getList as getContractList } from '@/api/business/contract-manage';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
const route = useRoute();
const router = useRouter();
const instance = getCurrentInstance();
const settlementImportListRefreshKey = 'settlement-import-list-refresh';
const formRef = ref();
const uploadRef = ref();
const files = ref([]);
const rows = ref([]);
const parseLoading = ref(false);
const rowSelection = ref([]);
const previewTab = ref('all');
const feeItemNames = ref([]);
const projects = ref([]);
const customerContracts = ref([]);
const carrierContracts = ref([]);
const draftLoading = ref(false);
const confirmLoading = ref(false);
const readonly = ref(false);
const createDefaultForm = () => ({
id: undefined,
batchNo: '',
projectId: '',
projectName: '',
settlementType: '',
settlementBillType: 'formal',
customerContractId: '',
customerContractName: '',
customerName: '',
carrierContractId: '',
carrierContractName: '',
carrierName: '',
waybillMode: 'new',
remark: '',
file: null,
});
const form = reactive(createDefaultForm());
const validateFile = (rule, value, callback) => {
if (rows.value.length || form.file) {
callback();
return;
}
callback(new Error('请上传结算单 Excel'));
};
const rules = {
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
settlementType: [{ required: true, message: '请选择结算类型', trigger: 'change' }],
settlementBillType: [{ required: true, message: '请选择结算单', trigger: 'change' }],
customerContractId: [{ required: true, message: '请选择客户合同', trigger: 'change' }],
carrierContractId: [{ required: true, message: '请选择承运商合同', trigger: 'change' }],
waybillMode: [{ required: true, message: '请选择运单类型', trigger: 'change' }],
file: [{ required: true, validator: validateFile, trigger: 'change' }],
};
const formReady = computed(
() =>
Boolean(form.projectId) &&
Boolean(form.settlementType) &&
Boolean(form.settlementBillType) &&
Boolean(form.customerContractId) &&
Boolean(form.carrierContractId)
);
const filteredCustomerContracts = computed(() => customerContracts.value);
const filteredCarrierContracts = computed(() => carrierContracts.value);
const extractRecords = response => {
const data = response?.data || response;
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 isSelectableContract = item => {
const approvalStatus = String(item?.approvalStatus || '').trim();
const contractStage = String(item?.contractStage || '').trim();
return (
['approved', 'change_approved'].includes(approvalStatus) && contractStage !== 'terminated'
);
};
/** 按项目加载客户合同、承运商合同(与运单导入一致,保证项目绑定可选) */
const loadContractsByProject = async (projectId, projectName = '') => {
if (!projectId) {
customerContracts.value = [];
carrierContracts.value = [];
return;
}
const [customerRes, carrierRes] = await Promise.all([
getContractList(1, 9999, {
projectId,
projectName: projectName || form.projectName || undefined,
contractCategory: '客户合同',
}),
getContractList(1, 9999, {
projectId,
projectName: projectName || form.projectName || undefined,
contractCategory: '承运商合同',
}),
]);
const matchProject = item => String(item.projectId ?? '') === String(projectId);
customerContracts.value = extractRecords(customerRes)
.filter(item => String(item.contractCategory || '').trim() === '客户合同')
.filter(matchProject)
.filter(isSelectableContract)
.map(normalizeContract)
.filter(Boolean);
carrierContracts.value = extractRecords(carrierRes)
.filter(item => String(item.contractCategory || '').trim() === '承运商合同')
.filter(matchProject)
.filter(isSelectableContract)
.map(normalizeContract)
.filter(Boolean);
};
const fixedDetailColumns = [
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 140 },
{ prop: 'batchNo', label: '运单批次号', editor: 'input', minWidth: 140 },
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 130 },
{
prop: 'vehicleNo',
label: '车牌号/航班号/船号/班列号',
editor: 'input',
minWidth: 190,
},
{ prop: 'driverName', label: '司机/船长', editor: 'input', minWidth: 120 },
{ prop: 'transportType', label: '运输方式', editor: 'input', minWidth: 110 },
{ prop: 'departureAddress', label: '起运地', editor: 'input', minWidth: 160 },
{ prop: 'arrivalAddress', label: '目的地', editor: 'input', minWidth: 160 },
{ prop: 'cargoName', label: '货物名称', editor: 'input', minWidth: 140 },
{ prop: 'actualStartDate', label: '开始时间', editor: 'date', minWidth: 140 },
{ prop: 'actualEndDate', label: '运输完成日期', editor: 'date', minWidth: 140 },
{ prop: 'unitPrice', label: '单价', editor: 'number', minWidth: 100, feeField: false },
{ prop: 'transportFee', label: '运费', editor: 'number', minWidth: 100 },
];
const detailColumns = computed(() => {
const feeColumns = feeItemNames.value.map(name => ({
prop: name,
label: name,
editor: 'number',
minWidth: 110,
isFeeItem: true,
}));
const historyColumns =
form.waybillMode === 'history'
? [
{
prop: 'waybillNo',
label: '运单号',
editor: 'input',
minWidth: 150,
},
]
: [];
return [
...historyColumns,
...fixedDetailColumns,
...feeColumns,
{
prop: 'settlementAmount',
label: '结算金额',
editor: 'number',
readonly: true,
minWidth: 120,
},
{ prop: 'remark', label: '备注', editor: 'input', minWidth: 140 },
];
});
const duplicateRows = computed(() => rows.value.filter(row => row._duplicate));
const previewRows = computed(() =>
previewTab.value === 'duplicate' ? duplicateRows.value : rows.value
);
const unwrapData = response => {
const body = response?.data || response || {};
return body?.data !== undefined ? body.data : body;
};
const normalizeOptionItem = (item, nameKeys = ['name', 'label']) => {
if (item == null) return null;
if (typeof item === 'string' || typeof item === 'number') {
return { id: item, name: String(item) };
}
const id = item.id ?? item.value ?? item.key;
let name = '';
for (const key of nameKeys) {
if (item[key] != null && String(item[key]).trim()) {
name = String(item[key]).trim();
break;
}
}
return { ...item, id, name: name || String(id ?? '') };
};
const normalizeProject = item => {
const normalized = normalizeOptionItem(item, ['projectName', 'label', 'name']);
if (!normalized) return null;
return {
...normalized,
id: normalized.id,
projectName: normalized.projectName || normalized.name || normalized.label || '',
};
};
const normalizeContract = item => {
const normalized = normalizeOptionItem(item, ['contractName', 'label', 'name']);
if (!normalized) return null;
return {
...normalized,
id: normalized.id,
contractName: normalized.contractName || normalized.name || '',
contractNo: normalized.contractNo || '',
projectId: normalized.projectId,
partyA: normalized.partyA || '',
partyB: normalized.partyB || '',
customerName: normalized.customerName || normalized.partyA || '',
carrierName: normalized.carrierName || normalized.partyB || '',
};
};
const formatContractLabel = item => {
const label = formatContractOptionLabel(item);
return label || item.contractName || item.name || String(item.id || '');
};
const toNumber = value => {
if (value === null || value === undefined || value === '') return 0;
const number = Number(value);
return Number.isFinite(number) ? number : 0;
};
const resolveCellValue = (row, column) => {
if (column.isFeeItem) {
const feeItems = row.feeItems || {};
return feeItems[column.prop] ?? row[column.prop] ?? '';
}
return row[column.prop] ?? '';
};
const formatCell = (row, column) => {
const value = resolveCellValue(row, column);
if (column.editor === 'number' || column.prop === 'settlementAmount') {
if (value === '' || value === null || value === undefined) return '';
return Number(value).toFixed(2);
}
return value ?? '';
};
const recalcSettlementAmount = row => {
const transportFee = toNumber(row.transportFee ?? row.freight);
row.transportFee = transportFee;
row.freight = transportFee;
if (!row.feeItems || typeof row.feeItems !== 'object') {
row.feeItems = {};
}
let feeSum = 0;
feeItemNames.value.forEach(name => {
const amount = toNumber(row.feeItems[name] ?? row[name]);
row.feeItems[name] = amount;
row[name] = amount;
feeSum += amount;
});
const settlementAmount = Number((transportFee + feeSum).toFixed(2));
row.settlementAmount = settlementAmount;
row.otherFeeTotal = Number(feeSum.toFixed(2));
row.freightTotal = settlementAmount;
};
const updateNumberCell = (row, column, value) => {
const [integer = '', decimal = ''] = String(value || '')
.replace(/[^\d.]/g, '')
.split('.');
const normalized =
decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
if (column.isFeeItem) {
if (!row.feeItems) row.feeItems = {};
row.feeItems[column.prop] = normalized;
row[column.prop] = normalized;
} else {
row[column.prop] = normalized;
if (column.prop === 'transportFee') {
row.freight = normalized;
}
if (column.prop === 'unitPrice') {
row.transportUnitPrice = normalized;
}
}
recalcSettlementAmount(row);
};
const importFieldText = value => String(value ?? '').trim();
/** 疑似重复:与历史运单匹配维度一致(项目/客户/承运商为表单级,行内比车号+完成日期+起运地+目的地+货物名称)。 */
const duplicateSignature = row =>
[
importFieldText(form.projectId),
importFieldText(form.customerContractId),
importFieldText(form.carrierContractId),
importFieldText(row.vehicleNo),
importFieldText(row.actualEndDate).slice(0, 10),
importFieldText(row.departureAddress),
importFieldText(row.arrivalAddress),
importFieldText(row.cargoName),
].join('\u0001');
const refreshDuplicateFlags = (list = rows.value) => {
const groups = new Map();
list.forEach(row => {
const signature = duplicateSignature(row);
if (row._duplicateConfirmed && row._duplicateSignature !== signature) {
row._duplicateConfirmed = false;
}
row._duplicateSignature = signature;
const group = groups.get(signature) || [];
group.push(row);
groups.set(signature, 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 normalizeParsedRow = (item, index) => {
const feeItems =
item.feeItems && typeof item.feeItems === 'object' ? { ...item.feeItems } : {};
feeItemNames.value.forEach(name => {
if (feeItems[name] == null && item[name] != null) {
feeItems[name] = item[name];
}
});
const row = {
_key: `${Date.now()}-${index}`,
...item,
batchNo: item.batchNo || form.batchNo || '',
loadingIdentifier: item.loadingIdentifier || item.loadingNo || '',
transportFee: item.transportFee ?? item.freight ?? item['*运输费'] ?? item['运输费'] ?? '',
unitPrice: item.unitPrice ?? item.transportUnitPrice ?? item['运输单价'] ?? item['单价'] ?? '',
feeItems,
};
row.transportUnitPrice = row.unitPrice;
row.freight = row.transportFee;
recalcSettlementAmount(row);
return row;
};
const stripInternalRowFields = row => {
const {
_key,
_duplicate,
_duplicateConfirmed,
_duplicateSignature,
_editing,
_editSnapshot,
...data
} = row;
const feeItems = { ...(data.feeItems || {}) };
feeItemNames.value.forEach(name => {
if (feeItems[name] == null && data[name] != null) {
feeItems[name] = data[name];
}
});
return {
...data,
transportUnitPrice: data.transportUnitPrice ?? data.unitPrice,
transportFee: data.transportFee ?? data.freight,
unitPrice: data.unitPrice ?? data.transportUnitPrice,
freight: data.freight ?? data.transportFee,
feeItems,
settlementAmount: data.settlementAmount,
};
};
const buildPayload = () => {
const { file, ...batch } = form;
return {
...batch,
settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal',
waybillMode: form.waybillMode === 'history' ? 'history' : 'new',
feeItemNames: [...feeItemNames.value],
rows: rows.value.map(stripInternalRowFields),
};
};
const onSettlementTypeChange = () => {
// 应收固定正式结算单;应付默认可选正式/预结算
if (form.settlementType !== 'payable') {
form.settlementBillType = 'formal';
} else if (!form.settlementBillType) {
form.settlementBillType = 'formal';
}
};
const autoSelectSingleContracts = () => {
const customers = filteredCustomerContracts.value;
if (customers.length === 1 && !form.customerContractId) {
form.customerContractId = customers[0].id;
onCustomerContractChange(customers[0].id);
}
const carriers = filteredCarrierContracts.value;
if (carriers.length === 1 && !form.carrierContractId) {
form.carrierContractId = carriers[0].id;
onCarrierContractChange(carriers[0].id);
}
};
const onProjectChange = async id => {
const item = projects.value.find(row => String(row.id) === String(id));
form.projectName = item?.projectName || '';
form.customerContractId = '';
form.customerContractName = '';
form.customerName = '';
form.carrierContractId = '';
form.carrierContractName = '';
form.carrierName = '';
customerContracts.value = [];
carrierContracts.value = [];
if (!id) return;
try {
await loadContractsByProject(id, form.projectName);
autoSelectSingleContracts();
} catch (error) {
ElMessage.error(error?.message || '加载项目合同失败');
}
};
const onCustomerContractChange = id => {
const item = filteredCustomerContracts.value.find(row => String(row.id) === String(id));
form.customerContractName = item?.contractName || '';
form.customerName = item?.customerName || item?.partyA || '';
};
const onCarrierContractChange = id => {
const item = filteredCarrierContracts.value.find(row => String(row.id) === String(id));
form.carrierContractName = item?.contractName || '';
form.carrierName = item?.carrierName || item?.partyB || '';
};
const downloadTemplate = async () => {
try {
const response = await api.exportTemplate();
downloadXls(response.data, '导入结算单模板.xlsx');
ElMessage.success('模板下载成功');
} catch (error) {
ElMessage.error('模板下载失败');
}
};
const resetUploadState = () => {
files.value = [];
form.file = null;
rows.value = [];
feeItemNames.value = [];
rowSelection.value = [];
};
const onFileRemove = () => {
resetUploadState();
};
/** limit=1 时再次选择文件会走 on-exceed,需主动替换并重新解析 */
const onFileExceed = uploadFiles => {
const file = Array.isArray(uploadFiles) ? uploadFiles[0] : uploadFiles;
if (!file) return;
uploadRef.value?.clearFiles?.();
parseSelectedFile(file, file.name);
};
const onFileChange = (uploadFile, fileList) => {
// 移除、失败等状态不触发解析;仅处理新选中的文件
if (uploadFile?.status && !['ready', 'success'].includes(uploadFile.status)) {
return;
}
const raw = uploadFile?.raw;
if (!raw) return;
files.value = (fileList || []).slice(-1);
parseSelectedFile(raw, uploadFile?.name || raw.name);
};
const parseSelectedFile = async (raw, displayName = '') => {
if (!formReady.value) {
ElMessage.warning('请先完善项目、结算类型、客户合同与承运商合同');
resetUploadState();
uploadRef.value?.clearFiles?.();
return;
}
const fileName = String(displayName || raw?.name || '').toLowerCase();
if (!/\.(xls|xlsx)$/.test(fileName)) {
ElMessage.error('请上传 .xls 或 .xlsx 文件');
resetUploadState();
uploadRef.value?.clearFiles?.();
return;
}
files.value = [
{
name: displayName || raw.name,
raw,
uid: Date.now(),
status: 'ready',
},
];
form.file = raw;
if (parseLoading.value) return;
parseLoading.value = true;
try {
const response = await api.parseFile(raw);
const data = unwrapData(response) || {};
feeItemNames.value = Array.isArray(data.feeItemNames) ? data.feeItemNames : [];
const parsedRows = Array.isArray(data.rows) ? data.rows : [];
rows.value = parsedRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();
formRef.value?.validateField('file');
if (Array.isArray(data.warnings) && data.warnings.length) {
ElMessage.warning(data.warnings.slice(0, 3).join(';'));
} else if (!rows.value.length) {
ElMessage.warning('解析成功,但未识别到有效明细行,请检查表头是否包含「*运输费」及数据行');
} else {
ElMessage.success(`解析成功,共 ${rows.value.length} 行`);
}
} catch (error) {
resetUploadState();
uploadRef.value?.clearFiles?.();
ElMessage.error(error?.message || '解析失败,请检查文件后重试');
} finally {
parseLoading.value = false;
}
};
const editRow = row => {
row._editSnapshot = JSON.parse(JSON.stringify(row));
row._editing = true;
};
const saveRow = row => {
recalcSettlementAmount(row);
row._editing = false;
delete row._editSnapshot;
refreshDuplicateFlags();
};
const cancelRow = row => {
if (row._editSnapshot) Object.assign(row, row._editSnapshot);
row._editing = false;
delete row._editSnapshot;
};
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 goBack = () => {
router.push({ path: '/settlement/settlement-import' });
};
const leaveAfterConfirm = () => {
sessionStorage.setItem(settlementImportListRefreshKey, '1');
instance?.proxy?.$router?.$avueRouter?.closeTag?.();
router.push({ path: '/settlement/settlement-import' });
};
const downloadFailureExcel = async failureRows => {
const response = await api.exportFailures({
feeItemNames: [...feeItemNames.value],
rows: failureRows,
});
const blob = response.data || response;
downloadXls(blob, `导入结算单失败明细${dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`);
};
const collectValidationErrors = async () => {
const payload = buildPayload();
const response = await api.validate(payload);
const result = unwrapData(response) || {};
const errorMap = new Map();
if (result?.success === false || (Array.isArray(result?.errors) && result.errors.length)) {
(result.errors || []).forEach(error => {
const index = Number(error.rowIndex);
if (Number.isNaN(index) || index < 0 || index >= rows.value.length) return;
errorMap.set(index, error.errorMessage || '校验失败');
});
}
return errorMap;
};
const saveDraft = async () => {
await formRef.value?.validateField([
'projectId',
'settlementType',
'settlementBillType',
'customerContractId',
'carrierContractId',
]);
if (!rows.value.length) {
ElMessage.warning('请先上传并解析结算单明细');
return;
}
draftLoading.value = true;
try {
const response = await api.saveDraft(buildPayload());
const batch = unwrapData(response) || {};
if (batch.id) form.id = batch.id;
if (batch.batchNo) form.batchNo = batch.batchNo;
ElMessage.success('保存草稿成功');
goBack();
} catch (error) {
ElMessage.error(error?.message || '保存草稿失败');
} finally {
draftLoading.value = false;
}
};
const confirmImport = async () => {
await formRef.value?.validate();
if (!rows.value.length) {
ElMessage.warning('请先上传并解析结算单明细');
return;
}
const duplicateCount = rows.value.filter(row => row._duplicate).length;
if (duplicateCount) {
await ElMessageBox.confirm(
`当前导入有${duplicateCount}条疑似重复数据,是否确认导入?`,
'提示',
{ type: 'warning' }
);
}
confirmLoading.value = true;
try {
const errorMap = await collectValidationErrors();
if (errorMap.size) {
const failureRows = [...errorMap.entries()]
.sort((left, right) => left[0] - right[0])
.map(([index, errorMessage]) => ({
...stripInternalRowFields(rows.value[index]),
errorMessage,
}));
await downloadFailureExcel(failureRows);
ElMessage.warning('部分数据校验失败,已下载失败明细');
return;
}
const response = await api.confirm(buildPayload());
const batch = unwrapData(response) || {};
if (batch.batchNo) form.batchNo = batch.batchNo;
ElMessage.success('确认导入成功');
leaveAfterConfirm();
} catch (error) {
if (error === 'cancel' || error === 'close') return;
ElMessage.error(error?.message || '确认导入失败');
} finally {
confirmLoading.value = false;
}
};
const loadOptions = async () => {
const response = await api.getOptions();
const options = unwrapData(response) || {};
projects.value = (options.projects || []).map(normalizeProject).filter(Boolean);
};
const loadDetail = async id => {
const response = await api.getDetail(id);
const detail = unwrapData(response) || {};
Object.assign(form, {
id: detail.id,
batchNo: detail.batchNo || '',
projectId: detail.projectId || '',
projectName: detail.projectName || '',
settlementType: detail.settlementType || '',
settlementBillType: detail.settlementBillType || 'formal',
customerContractId: detail.customerContractId || '',
customerContractName: detail.customerContractName || '',
customerName: detail.customerName || '',
carrierContractId: detail.carrierContractId || '',
carrierContractName: detail.carrierContractName || '',
carrierName: detail.carrierName || '',
waybillMode: detail.waybillMode || 'new',
remark: detail.remark || '',
file: null,
});
if (detail.projectId) {
await loadContractsByProject(detail.projectId, detail.projectName || '');
}
feeItemNames.value = Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [];
const detailRows = Array.isArray(detail.rows) ? detail.rows : [];
rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();
readonly.value = detail.importStatus && detail.importStatus !== 'draft';
};
const initPage = async () => {
await loadOptions();
const id = route.query.id;
if (id) {
await loadDetail(id);
return;
}
try {
const codeResponse = await api.getNextCode();
form.batchNo = unwrapData(codeResponse) || '';
} catch (error) {
form.batchNo = '';
}
};
onMounted(() => {
initPage().catch(error => {
ElMessage.error(error?.message || '页面初始化失败');
});
});
</script>
<style scoped lang="scss">
.settlement-import-create {
padding: 16px 24px 24px;
background: #fff;
}
.settlement-import-create__form-panel {
margin-bottom: 8px;
}
.settlement-import-create__form :deep(.el-form-item) {
margin-bottom: 16px;
}
.settlement-import-create__waybill-mode :deep(.el-form-item__label) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.settlement-import-create__label-tip {
color: #909399;
cursor: help;
font-size: 14px;
}
.settlement-import-create__upload {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.settlement-import-create__upload-tip {
color: #909399;
font-size: 12px;
}
.settlement-import-create__detail-panel {
margin-top: 8px;
}
.settlement-import-create__detail-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.dialog-section-title {
position: relative;
padding-left: 12px;
color: #303133;
font-size: 16px;
font-weight: 600;
&::before {
position: absolute;
top: 2px;
left: 0;
width: 4px;
height: 16px;
border-radius: 2px;
background: #409eff;
content: '';
}
}
.settlement-import-create__table {
--el-table-border-color: #eff1f7;
}
.settlement-import-create__table :deep(.el-table__body tr:nth-child(even) > td.el-table__cell),
.settlement-import-create__table
:deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--left),
.settlement-import-create__table
:deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--right) {
background: #fafafa;
}
.settlement-import-create__table :deep(.is-duplicate-row > td.el-table__cell) {
background: #fef0f0 !important;
}
.settlement-import-create__row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settlement-import-create__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #eff1f7;
}
</style>