Files
tms-erp-web/src/views/settlement/components/settlement-import-page.vue
T

1681 lines
57 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.carrierOptionValue"
filterable
clearable
:disabled="readonly || !form.projectId"
style="width: 100%"
:placeholder="form.projectId ? '请选择承运商合同客商' : '请先选择项目'"
@change="onCarrierContractChange"
>
<el-option
v-for="item in filteredCarrierContractOptions"
:key="item.optionValue"
:label="item.optionLabel"
:value="item.optionValue"
/>
</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 v-if="!readonly" label="结算单上传" prop="file">
<div class="settlement-import-create__upload">
<el-upload
ref="uploadRef"
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:limit="1"
:disabled="!formReady"
:file-list="files"
:on-change="onFileChange"
:on-exceed="onFileExceed"
:on-remove="onFileRemove"
>
<el-button type="primary" :disabled="!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="readonly || !column.editor || column.readonly"
>
<template #default="{ row }">
<template v-if="!readonly && column.editor && !column.readonly">
<el-date-picker
v-if="column.editor === 'date'"
:key="`${row._key}-${column.prop}`"
v-model="row[column.prop]"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
<el-select
v-else-if="column.editor === 'select' && column.prop === 'transportType'"
:key="`${row._key}-${column.prop}`"
v-model="row[column.prop]"
clearable
filterable
placeholder="请选择运输方式"
style="width: 100%"
>
<el-option
v-for="item in transportTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div
v-else-if="column.editor === 'waybillSelect'"
class="settlement-import-create__waybill-select"
>
<el-input v-model="row.waybillNo" clearable placeholder="运单号" />
<el-link type="primary" :underline="false" @click="openHistoryWaybillDialog(row)">
选择
</el-link>
</div>
<el-cascader
v-else-if="column.editor === 'cargoType'"
:key="`${row._key}-${column.prop}`"
:model-value="resolveCargoTypePath(row)"
:options="cargoTypeSelectOptions(row)"
:props="cargoTypeCascaderProps"
clearable
filterable
placeholder="请选择货物类型"
style="width: 100%"
@change="value => handleCargoTypeChange(row, value)"
/>
<el-input
v-else-if="column.editor === 'number' || column.editor === 'optionalNumber'"
:key="`${row._key}-${column.prop}`"
:model-value="resolveCellValue(row, column)"
inputmode="decimal"
:placeholder="`请输入${column.label}`"
@input="value => updateNumberCell(row, column, value)"
/>
<el-input
v-else
:key="`${row._key}-${column.prop}`"
v-model="row[column.prop]"
:placeholder="`请输入${column.label}`"
@update:model-value="value => syncEditedText(row, column, value)"
/>
</template>
<template v-else-if="column.editor === 'waybillSelect' && !readonly">
<div class="settlement-import-create__waybill-select">
<span>{{ row.waybillNo || '-' }}</span>
<el-link type="primary" :underline="false" @click="openHistoryWaybillDialog(row)">
选择
</el-link>
</div>
</template>
<span v-else>{{ formatCell(row, column) }}</span>
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="settlement-import-create__row-actions">
<el-link v-if="row._duplicate" type="primary" @click="confirmDuplicateRow(row)">
确认
</el-link>
<el-link type="danger" @click="deleteRow(row)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
</section>
<div v-if="!readonly" class="settlement-import-create__footer">
<el-button @click="goBack">取消</el-button>
<el-button :loading="draftLoading" @click="saveDraft">保存草稿</el-button>
<el-button type="primary" :loading="confirmLoading" @click="confirmImport">
确认导入
</el-button>
</div>
<el-dialog
v-model="historyWaybillVisible"
title="选择历史运单"
width="960px"
append-to-body
destroy-on-close
>
<div class="settlement-import-create__history-tip">
仅展示相同项目、客户合同、承运商合同下,尚未生成当前结算类型应收/应付明细的运单
</div>
<el-table
v-loading="historyWaybillLoading"
:data="historyWaybillRows"
border
max-height="420"
highlight-current-row
@row-dblclick="selectHistoryWaybill"
@current-change="row => (historyWaybillCurrent = row)"
>
<el-table-column prop="waybillNo" label="运单号" min-width="140" />
<el-table-column prop="vehicleNo" label="车号" min-width="120" />
<el-table-column prop="departureAddress" label="起运地" min-width="140" show-overflow-tooltip />
<el-table-column prop="arrivalAddress" label="目的地" min-width="140" show-overflow-tooltip />
<el-table-column prop="cargoName" label="货物名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="actualEndDate" label="完成日期" min-width="120" />
<el-table-column prop="quantity" label="数量" width="90" />
<el-table-column prop="quantityUnit" label="数量单位" width="90" />
<el-table-column prop="mileage" label="里程" width="90" />
</el-table>
<template #footer>
<el-button @click="historyWaybillVisible = false">取消</el-button>
<el-button type="primary" @click="confirmHistoryWaybill">确定</el-button>
</template>
</el-dialog>
</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 getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getContractList } from '@/api/business/contract-manage';
import { getDictionary } from '@/api/system/dictbiz';
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 parsedHeaders = ref([]);
const projects = ref([]);
const customerContracts = ref([]);
const carrierContracts = ref([]);
const draftLoading = ref(false);
const confirmLoading = ref(false);
const readonly = ref(false);
const transportTypeOptions = ref([]);
const cargoTypeOptions = ref([]);
const cargoTypeFlatOptions = ref([]);
const cargoTypeCascaderProps = {
label: 'cargoName',
value: 'id',
children: 'children',
emitPath: true,
};
const historyWaybillVisible = ref(false);
const historyWaybillLoading = ref(false);
const historyWaybillRows = ref([]);
const historyWaybillCurrent = ref(null);
const historyWaybillTargetRow = ref(null);
const createDefaultForm = () => ({
id: undefined,
batchNo: '',
projectId: '',
projectName: '',
settlementType: '',
settlementBillType: 'formal',
customerContractId: '',
customerContractName: '',
customerName: '',
carrierContractId: '',
carrierContractName: '',
carrierOptionValue: '',
carrierPartyId: '',
carrierPartyRole: '',
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 parsePartyRows = (partyJson, fallbackName = '', partyRole = 'B') => {
let rows = [];
try {
const parsed = partyJson ? JSON.parse(partyJson) : [];
if (Array.isArray(parsed)) {
rows = parsed
.map(item => ({
id: item.id || '',
customerId: item.customerId || '',
customerName: String(item.customerName || '').trim(),
partyRole,
}))
.filter(item => item.customerName);
}
} catch (error) {
rows = [];
}
if (rows.length) return rows;
return String(fallbackName || '')
.split(/[、,,]/)
.map(name => name.trim())
.filter(Boolean)
.map(name => ({ id: '', customerId: '', customerName: name, partyRole }));
};
const filteredCarrierContractOptions = computed(() => {
const options = [];
filteredCarrierContracts.value.forEach(contract => {
const parties = [
...parsePartyRows(contract.partyBJson, contract.partyB, 'B'),
...parsePartyRows(contract.partyCJson, contract.partyC, 'C'),
];
if (!parties.length && contract.carrierName) {
parties.push({
id: '',
customerId: '',
customerName: contract.carrierName,
partyRole: 'B',
});
}
parties.forEach(party => {
const roleLabel = party.partyRole === 'C' ? '丙方' : '乙方';
const optionValue = [contract.id, party.id || '', party.customerName].join('__');
options.push({
...contract,
optionValue,
optionLabel: `${party.customerName}(${roleLabel}) - ${formatContractLabel(contract)}`,
carrierPartyId: party.id || '',
carrierPartyRole: party.partyRole,
carrierName: party.customerName,
});
});
});
return options;
});
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 columnsBeforeFee = [
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 130 },
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 140 },
{
prop: 'vehicleNo',
label: '车牌号/航班号/船号/班列号',
editor: 'input',
minWidth: 190,
},
{ prop: 'transportType', label: '运输方式', editor: 'select', minWidth: 140 },
{ prop: 'driverName', label: '司机/船长姓名', editor: 'input', minWidth: 130 },
{ prop: 'driverPhone', label: '司机/船长手机号', editor: 'input', minWidth: 150 },
{ prop: 'departureAddress', label: '发货地址', editor: 'input', minWidth: 160 },
{ prop: 'departureContact', label: '发货联系人', editor: 'input', minWidth: 120 },
{ prop: 'departurePhone', label: '发货联系人电话', editor: 'input', minWidth: 140 },
{ prop: 'arrivalAddress', label: '到货地址', editor: 'input', minWidth: 160 },
{ prop: 'arrivalContact', label: '收货联系人', editor: 'input', minWidth: 120 },
{ prop: 'arrivalPhone', label: '收货联系人电话', editor: 'input', minWidth: 140 },
{ prop: 'cargoName', label: '货物名称', editor: 'input', minWidth: 140 },
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', minWidth: 200 },
{ prop: 'packageType', label: '包装', editor: 'input', minWidth: 100 },
{ prop: 'quantity', label: '数量', editor: 'number', minWidth: 100 },
{ prop: 'quantityUnit', label: '数量单位', editor: 'input', minWidth: 110 },
{ prop: 'specification', label: '规格', editor: 'input', minWidth: 110 },
{ prop: 'model', label: '型号', editor: 'input', minWidth: 110 },
{ prop: 'mileage', label: '里程(km)', editor: 'optionalNumber', minWidth: 110 },
{ prop: 'unitPrice', label: '运输单价', editor: 'optionalNumber', minWidth: 110 },
{ prop: 'transportFee', label: '运输费', editor: 'number', minWidth: 110 },
];
const columnsAfterFee = [
{ prop: 'actualStartDate', label: '实际发货时间', editor: 'date', minWidth: 150 },
{ prop: 'actualEndDate', label: '实际完成时间', editor: 'date', minWidth: 150 },
{ prop: 'planStartDate', label: '预计发货时间', editor: 'date', minWidth: 150 },
{ prop: 'planEndDate', label: '预计完成时间', editor: 'date', minWidth: 150 },
{ prop: 'remark', label: '备注', editor: 'input', minWidth: 160 },
{ prop: 'waybillIdentifier', label: '同一运单标识号', editor: 'input', minWidth: 150 },
];
const headerColumnMap = {};
[...columnsBeforeFee, ...columnsAfterFee].forEach(column => {
headerColumnMap[column.label] = column;
headerColumnMap[`*${column.label}`] = column;
});
headerColumnMap['结算金额'] = {
prop: 'settlementAmount',
label: '结算金额',
editor: 'number',
minWidth: 120,
};
headerColumnMap['里程'] = headerColumnMap['里程(km)'];
headerColumnMap['单价'] = headerColumnMap['运输单价'];
headerColumnMap['运输类型'] = headerColumnMap['运输方式'];
headerColumnMap['*运输类型'] = headerColumnMap['运输方式'];
const VEHICLE_ALIAS_KEYS = [
'*车牌号/航班号/船号/班列号',
'车牌号/航班号/船号/班列号',
'*车牌号/船班号/船次号/班次号',
'车牌号/船班号/船次号/班次号',
'车号',
];
const readFilledText = value => {
const text = String(value ?? '').trim();
return text;
};
const applyVehicleNo = (row, value) => {
const vehicleNo = readFilledText(value);
row.vehicleNo = vehicleNo;
VEHICLE_ALIAS_KEYS.forEach(key => {
row[key] = vehicleNo;
});
};
const syncEditedText = (row, column, value) => {
if (column.prop !== 'vehicleNo') return;
applyVehicleNo(row, value);
};
const feeColumn = name => ({
prop: name,
label: name,
editor: 'number',
minWidth: 110,
isFeeItem: true,
});
const columnFromHeader = header => {
const name = String(header || '').trim();
if (!name || name === '序号') return null;
if (feeItemNames.value.includes(name)) return feeColumn(name);
const mapped = headerColumnMap[name];
if (mapped) return { ...mapped, label: mapped.label || name.replace(/^\*/, '') };
return {
prop: name,
label: name.replace(/^\*/, ''),
editor: 'input',
minWidth: 140,
};
};
const dedupeColumns = columns => {
const seen = new Set();
return columns.filter(column => {
if (!column?.prop || seen.has(column.prop)) return false;
seen.add(column.prop);
return true;
});
};
const settlementAmountColumn = {
prop: 'settlementAmount',
label: '结算金额',
editor: 'number',
minWidth: 120,
};
/** 模板已去掉结算金额;明细仍展示,并插在费用项之后、日期列之前 */
const ensureSettlementAmountColumn = columns => {
if (columns.some(column => column.prop === 'settlementAmount')) {
return columns;
}
const result = [...columns];
let insertAt = result.findIndex(column =>
['actualStartDate', 'actualEndDate', 'planStartDate', 'planEndDate', 'remark', 'waybillIdentifier'].includes(
column.prop
)
);
if (insertAt < 0) {
const lastFeeIndex = [...result].map((column, index) => (column.isFeeItem ? index : -1)).filter(index => index >= 0).pop();
const transportFeeIndex = result.findIndex(column => column.prop === 'transportFee');
insertAt =
lastFeeIndex !== undefined
? lastFeeIndex + 1
: transportFeeIndex >= 0
? transportFeeIndex + 1
: result.length;
}
result.splice(insertAt, 0, { ...settlementAmountColumn });
return result;
};
const detailColumns = computed(() => {
const historyColumns =
form.waybillMode === 'history'
? [
{
prop: 'waybillNo',
label: '运单号',
editor: 'waybillSelect',
minWidth: 180,
},
]
: [];
const fallback = [
...columnsBeforeFee,
...feeItemNames.value.map(name => feeColumn(name)),
{ ...settlementAmountColumn },
...columnsAfterFee,
];
const uploaded = parsedHeaders.value.map(columnFromHeader).filter(Boolean);
const columns = ensureSettlementAmountColumn(
dedupeColumns(uploaded.length ? uploaded : fallback)
);
return [...historyColumns, ...columns];
});
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 || '',
partyC: normalized.partyC || '',
partyAJson: normalized.partyAJson || '',
partyBJson: normalized.partyBJson || '',
partyCJson: normalized.partyCJson || '',
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.editor === 'optionalNumber' ||
column.prop === 'settlementAmount'
) {
if (value === '' || value === null || value === undefined) return '';
const number = Number(value);
return Number.isFinite(number) ? number.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;
row['运费合计'] = settlementAmount;
};
const syncOtherFeeTotal = row => {
if (!row.feeItems || typeof row.feeItems !== 'object') {
row.feeItems = {};
}
let feeSum = 0;
feeItemNames.value.forEach(name => {
feeSum += toNumber(row.feeItems[name] ?? row[name]);
});
row.otherFeeTotal = Number(feeSum.toFixed(2));
};
const updateNumberCell = (row, column, value) => {
const raw = String(value ?? '').trim();
if (!raw) {
if (column.isFeeItem) {
if (!row.feeItems) row.feeItems = {};
row.feeItems[column.prop] = '';
row[column.prop] = '';
syncOtherFeeTotal(row);
return;
}
row[column.prop] = '';
if (column.prop === 'transportFee') row.freight = '';
if (column.prop === 'unitPrice') row.transportUnitPrice = '';
if (column.prop === 'settlementAmount') {
row.freightTotal = '';
row['运费合计'] = '';
}
return;
}
const [integer = '', decimal = ''] = raw.replace(/[^\d.]/g, '').split('.');
const normalized =
decimal || raw.includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
if (column.isFeeItem) {
if (!row.feeItems) row.feeItems = {};
row.feeItems[column.prop] = normalized;
row[column.prop] = normalized;
syncOtherFeeTotal(row);
return;
}
row[column.prop] = normalized;
if (column.prop === 'transportFee') {
row.freight = normalized;
}
if (column.prop === 'unitPrice') {
row.transportUnitPrice = normalized;
}
if (column.prop === 'settlementAmount') {
row.freightTotal = normalized;
row['运费合计'] = normalized;
}
};
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['单价'] ?? '',
mileage: item.mileage ?? item['里程(km)'] ?? item['里程'] ?? '',
quantity: item.quantity ?? item['*数量'] ?? item['数量'] ?? '',
quantityUnit: item.quantityUnit ?? item['*数量单位'] ?? item['数量单位'] ?? '',
model: item.model ?? item['型号'] ?? '',
specification: item.specification ?? item['规格'] ?? '',
cargoType: item.cargoType ?? item['*货物类型'] ?? item['货物类型'] ?? '',
settlementAmount:
item.settlementAmount ?? item['结算金额'] ?? '',
feeItems,
};
const vehicleNo =
item.vehicleNo !== undefined && item.vehicleNo !== null
? readFilledText(item.vehicleNo)
: readFilledText(VEHICLE_ALIAS_KEYS.map(key => item[key]).find(value => readFilledText(value)));
applyVehicleNo(row, vehicleNo);
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,
};
};
/** 合并费用列名与各行 feeItems 键,避免保存/回显时丢动态费用列 */
const collectFeeItemNames = (preferred = [], detailRows = []) => {
const names = [];
const seen = new Set();
const pushName = value => {
const name = String(value || '').trim();
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
};
(preferred || []).forEach(pushName);
(detailRows || []).forEach(row => {
const feeItems = row?.feeItems;
if (!feeItems || typeof feeItems !== 'object' || Array.isArray(feeItems)) return;
Object.keys(feeItems).forEach(pushName);
});
return names;
};
const buildPayload = () => {
const { file, ...batch } = form;
const payloadRows = rows.value.map(stripInternalRowFields);
return {
...batch,
settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal',
waybillMode: form.waybillMode === 'history' ? 'history' : 'new',
feeItemNames: collectFeeItemNames(feeItemNames.value, payloadRows),
rows: payloadRows,
};
};
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 = filteredCarrierContractOptions.value;
if (carriers.length === 1 && !form.carrierContractId) {
onCarrierContractChange(carriers[0].optionValue);
}
};
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.carrierOptionValue = '';
form.carrierPartyId = '';
form.carrierPartyRole = '';
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 = optionValue => {
const item = filteredCarrierContractOptions.value.find(
row => String(row.optionValue) === String(optionValue)
);
form.carrierOptionValue = item?.optionValue || '';
form.carrierContractId = item?.id || '';
form.carrierContractName = item?.contractName || '';
form.carrierPartyId = item?.carrierPartyId || '';
form.carrierPartyRole = item?.carrierPartyRole || '';
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 = [];
parsedHeaders.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 : [];
parsedHeaders.value = Array.isArray(data.headers) ? data.headers : [];
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 formatCargoTypeLabel = item =>
item?.cargoName || item?.name || item?.typeName || item?.label || '';
const buildCargoTypeTree = cargoTypes => {
const flatCargoTypes = [];
const collectCargoTypes = list => {
(list || []).forEach(item => {
flatCargoTypes.push({ ...item, children: undefined });
if (item.children?.length) collectCargoTypes(item.children);
});
};
collectCargoTypes(cargoTypes);
const nodeMap = new Map();
const codeMap = new Map();
flatCargoTypes.forEach(item => {
const id = item.id ?? item.cargoCode ?? item.code;
if (id === undefined || id === null) return;
const node = {
...item,
id: String(id),
cargoName: formatCargoTypeLabel(item),
cargoCode: item.cargoCode || item.code || '',
parentId: item.parentId === undefined || item.parentId === null ? '' : String(item.parentId),
parentCargoCode: item.parentCargoCode || item.parentCode || '',
children: [],
};
nodeMap.set(node.id, node);
if (node.cargoCode) codeMap.set(String(node.cargoCode), node);
});
const rootNodes = [];
nodeMap.forEach(node => {
const parent =
nodeMap.get(node.parentId) ||
codeMap.get(String(node.parentCargoCode || '')) ||
codeMap.get(String(node.parentId || ''));
if (parent && parent !== node && Number(node.typeLevel) !== 1) parent.children.push(node);
else rootNodes.push(node);
});
const normalize = (cargoType, level = 1, parentPath = []) => {
if (level > 2) return null;
const id = String(cargoType.id);
const path = [...parentPath, id];
const children =
level === 1
? (cargoType.children || []).map(item => normalize(item, level + 1, path)).filter(Boolean)
: [];
return {
...cargoType,
id,
cargoName: formatCargoTypeLabel(cargoType),
path,
leaf: level === 2,
children: children.length ? children : undefined,
};
};
return rootNodes.map(item => normalize(item, 1)).filter(Boolean);
};
const flattenCargoTypeOptions = options => {
const result = [];
const collect = list =>
(list || []).forEach(item => {
result.push(item);
if (item.children?.length) collect(item.children);
});
collect(options);
return result;
};
const resolveCargoTypePath = row => {
if (Array.isArray(row.cargoTypePath) && row.cargoTypePath.length) return row.cargoTypePath;
const current = String(row.cargoType || row.cargoTypeCode || '').trim();
if (!current) return [];
const matched = cargoTypeFlatOptions.value.find(item =>
[item.cargoName, item.name, item.typeName, item.cargoCode, item.code].some(
value => String(value || '').trim() === current
)
);
if (matched?.path?.length) return matched.path;
return [`custom:${current}`];
};
const cargoTypeSelectOptions = row => {
const path = resolveCargoTypePath(row);
const customId = path.find(value => String(value).startsWith('custom:'));
if (!customId) return cargoTypeOptions.value;
const name = String(customId).slice('custom:'.length);
return [{ id: customId, cargoName: name, leaf: true }, ...cargoTypeOptions.value];
};
const handleCargoTypeChange = (row, value) => {
const path = Array.isArray(value) ? value : [];
const leafId = path.length ? String(path[path.length - 1]) : '';
if (!leafId) {
row.cargoTypePath = [];
row.cargoType = '';
row.cargoTypeCode = '';
return;
}
if (leafId.startsWith('custom:')) {
row.cargoTypePath = path;
row.cargoType = leafId.slice('custom:'.length);
row.cargoTypeCode = '';
return;
}
const cargoType = cargoTypeFlatOptions.value.find(item => String(item.id) === leafId);
row.cargoTypePath = cargoType?.path || path;
row.cargoType = cargoType?.cargoName || '';
row.cargoTypeCode = cargoType?.cargoCode || cargoType?.code || '';
};
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 openHistoryWaybillDialog = async row => {
if (!form.projectId || !form.customerContractId || !form.carrierContractId) {
ElMessage.warning('请先选择项目、客户合同和承运商合同');
return;
}
if (!form.settlementType) {
ElMessage.warning('请先选择结算类型');
return;
}
historyWaybillTargetRow.value = row;
historyWaybillCurrent.value = null;
historyWaybillVisible.value = true;
historyWaybillLoading.value = true;
try {
const response = await api.getHistoryWaybills({
projectId: form.projectId,
customerContractId: form.customerContractId,
carrierContractId: form.carrierContractId,
settlementType: form.settlementType,
});
historyWaybillRows.value = unwrapData(response) || [];
} catch (error) {
historyWaybillRows.value = [];
ElMessage.error(error?.message || '加载历史运单失败');
} finally {
historyWaybillLoading.value = false;
}
};
const applyHistoryWaybill = waybill => {
const target = historyWaybillTargetRow.value;
if (!target || !waybill) return;
target.waybillNo = waybill.waybillNo || '';
target.matchedWaybillId = waybill.id;
target.vehicleNo = waybill.vehicleNo || target.vehicleNo;
target.departureAddress = waybill.departureAddress || target.departureAddress;
target.arrivalAddress = waybill.arrivalAddress || target.arrivalAddress;
target.cargoName = waybill.cargoName || target.cargoName;
target.cargoType = waybill.cargoType || target.cargoType;
target.quantity = waybill.quantity ?? target.quantity;
target.quantityUnit = waybill.quantityUnit || target.quantityUnit;
target.model = waybill.model || target.model;
target.mileage =
waybill.mileage === null || waybill.mileage === undefined ? target.mileage : waybill.mileage;
target.actualStartDate = waybill.actualStartDate
? dayjs(waybill.actualStartDate).format('YYYY-MM-DD')
: target.actualStartDate;
target.actualEndDate = waybill.actualEndDate
? dayjs(waybill.actualEndDate).format('YYYY-MM-DD')
: target.actualEndDate;
target.driverName = waybill.driverName || target.driverName;
refreshDuplicateFlags();
};
const selectHistoryWaybill = row => {
applyHistoryWaybill(row);
historyWaybillVisible.value = false;
};
const confirmHistoryWaybill = () => {
if (!historyWaybillCurrent.value) {
ElMessage.warning('请选择一条运单');
return;
}
applyHistoryWaybill(historyWaybillCurrent.value);
historyWaybillVisible.value = false;
};
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('保存草稿成功');
leaveAfterConfirm();
} 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 loadTransportTypeOptions = async () => {
try {
// 运输方式取业务字典 transport_type 的字典名称(dictValue),不写死枚举
const response = await getDictionary({ code: 'transport_type' });
const list = unwrapData(response) || [];
transportTypeOptions.value = (Array.isArray(list) ? list : [])
.map(item => {
const label = String(item.dictValue || item.label || item.name || '').trim();
if (!label || String(item.dictKey) === '-1') return null;
return { label, value: label };
})
.filter(Boolean);
} catch (error) {
transportTypeOptions.value = [];
}
};
const loadCargoTypeOptions = async () => {
try {
const response = await getCargoTypeList(1, 9999);
const body = unwrapData(response) || {};
const records = Array.isArray(body) ? body : body.records || body.data || [];
cargoTypeOptions.value = buildCargoTypeTree(records);
cargoTypeFlatOptions.value = flattenCargoTypeOptions(cargoTypeOptions.value);
} catch (error) {
cargoTypeOptions.value = [];
cargoTypeFlatOptions.value = [];
}
};
const loadOptions = async () => {
const response = await api.getOptions();
const options = unwrapData(response) || {};
projects.value = (options.projects || []).map(normalizeProject).filter(Boolean);
await Promise.all([loadTransportTypeOptions(), loadCargoTypeOptions()]);
};
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 || '');
}
parsedHeaders.value = [];
const detailRows = Array.isArray(detail.rows) ? detail.rows : [];
// 先按库中列名与明细 feeItems 合并,再规范化行数据,保证草稿再打开仍展示全部费用列
feeItemNames.value = collectFeeItemNames(
Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [],
detailRows
);
rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();
// 查看模式始终只读;编辑时仅草稿可改,已完成/已作废强制只读
readonly.value =
route.query.mode === 'view' ||
Boolean(detail.importStatus && detail.importStatus !== 'draft');
};
const initPage = async () => {
await loadOptions();
const id = route.query.id;
if (route.query.mode === 'view') {
readonly.value = true;
}
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;
}
.settlement-import-create__waybill-select {
display: flex;
align-items: center;
gap: 8px;
}
.settlement-import-create__history-tip {
margin-bottom: 8px;
color: #909399;
font-size: 13px;
}
</style>