6f2383a7e5
付款申请汇票筛选与提示去重、结算菜单拆分与金蝶同步弹框、应收应付详情高度、合同计费与半月结调整,以及导入运单批量删除校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
1119 lines
36 KiB
Vue
1119 lines
36 KiB
Vue
<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>
|