c0ba9ba39b
Co-authored-by: Cursor <cursoragent@cursor.com>
1681 lines
57 KiB
Vue
1681 lines
57 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.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>
|