Files
tms-erp-web/src/views/business/components/waybill-import-dialog.vue
T

2021 lines
73 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>
<component
v-if="!createPage"
:is="standalone ? 'div' : 'el-dialog'"
v-model="visible"
title="导入运单"
width="90%"
append-to-body
destroy-on-close
:class="{ 'waybill-import-page': standalone }"
@closed="handleClosed"
>
<section v-show="searchVisible" class="waybill-import-toolbar__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="waybill-import-toolbar__search-grid">
<el-form-item label="运单批次号">
<el-input v-model="query.batchNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="query.carrierId" clearable filterable placeholder="请选择">
<el-option
v-for="item in queryCarriers"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="query.createTimeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="~"
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
/>
</el-form-item>
<el-form-item label="创建人">
<el-select v-model="query.createUser" clearable filterable placeholder="请选择">
<el-option
v-for="item in creators"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<div class="waybill-import-toolbar__search-actions">
<el-button type="primary" @click="loadBatches">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</div>
</el-form>
</section>
<div class="avue-crud__header waybill-import-toolbar__actions">
<div class="avue-crud__left">
<el-button type="primary" @click="handleCreate">新建导入</el-button>
<el-button type="danger" plain :disabled="!selected.length" @click="removeBatches"
>批量删除</el-button
>
</div>
<div class="avue-crud__right">
<el-button icon="el-icon-refresh" circle @click="loadBatches" />
<el-button icon="el-icon-operation" circle @click="columnSettingVisible = true" />
<el-button icon="el-icon-search" circle @click="searchVisible = !searchVisible" />
<el-button icon="el-icon-grid" circle @click="toggleTableSize" />
</div>
</div>
<section class="waybill-import-toolbar__table-panel">
<el-table
:data="batches"
:size="tableSize"
border
@selection-change="selected = $event"
>
<el-table-column type="selection" width="50" /><el-table-column
type="index"
label="序号"
width="70"
/>
<el-table-column
v-if="columnVisible.batchNo"
prop="batchNo"
label="运单批次号"
min-width="150"
/><el-table-column
v-if="columnVisible.projectName"
prop="projectName"
label="项目名称"
min-width="150"
/>
<el-table-column
v-if="columnVisible.carrierName"
prop="carrierName"
label="承运商"
min-width="140"
/><el-table-column
v-if="columnVisible.carrierType"
prop="carrierType"
label="承运类型"
min-width="120"
/>
<el-table-column
v-if="columnVisible.waybillCount"
prop="waybillCount"
label="运单数"
width="90"
/> <el-table-column
v-if="columnVisible.importTypeName"
prop="importTypeName"
label="导入方式"
width="100"
/>
<el-table-column
v-if="columnVisible.statusName"
prop="statusName"
label="状态"
width="100"
/>
<el-table-column
v-if="columnVisible.createUserName"
prop="createUserName"
label="创建人"
min-width="120"
>
<template #default="{ row }">{{ row.createUserName || '-' }}</template>
</el-table-column>
<el-table-column
v-if="columnVisible.createTime"
prop="createTime"
label="创建时间"
min-width="170"
sortable
/><el-table-column
v-if="columnVisible.updateTime"
prop="updateTime"
label="更新时间"
min-width="170"
sortable
/>
<el-table-column
label="操作"
width="180"
fixed="right"
>
<template #default="{ row }">
<el-link
v-if="row.importStatus !== 'draft'"
type="primary"
@click="openDetail(row)"
>查看</el-link
>
<el-link
v-if="row.importStatus === 'draft'"
type="primary"
@click="editBatch(row)"
>编辑</el-link
>
<el-link
v-if="row.importStatus === 'draft'"
type="danger"
@click="removeBatch(row)"
>删除</el-link
>
</template>
</el-table-column>
</el-table>
<div class="waybill-import-toolbar__pagination">
<el-pagination
background
v-model:current-page="page.current"
v-model:page-size="page.size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="page.total"
@current-change="loadBatches"
@size-change="loadBatches"
/>
</div>
</section>
</component>
<el-dialog
v-model="columnSettingVisible"
title="列设置"
width="360px"
append-to-body
>
<el-checkbox-group v-model="visibleColumnKeys" class="waybill-import-toolbar__column-setting">
<el-checkbox v-for="item in columnOptions" :key="item.prop" :label="item.prop">
{{ item.label }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="columnSettingVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" title="导入运单详情" width="90%" append-to-body>
<section-card class="waybill-search">
<el-form :inline="true" :model="detailQuery"
>
<el-form-item label="车牌号/船班号/船次号/班次号"
><el-input v-model="detailQuery.vehicleNo" clearable /></el-form-item
><el-form-item label="司机/船长姓名"
><el-input v-model="detailQuery.driverName" clearable placeholder="请输入" /></el-form-item
><el-button type="primary" @click="loadDetails">查询</el-button></el-form
>
</section-card>
<el-table :data="details" border
><el-table-column type="index" label="序号" width="65" /><el-table-column
prop="batchNo"
label="运单批次号"
min-width="140"
/><el-table-column prop="originalNo" label="原始单号" min-width="130" /><el-table-column
prop="loadingIdentifier"
label="配载标识号"
min-width="130"
>
<template #default="{ row }">{{ row.loadingIdentifier || row.loadingNo || '' }}</template>
</el-table-column><el-table-column
prop="vehicleNo"
label="车牌号/船班号/船次号/班次号"
min-width="190"
/><el-table-column prop="transportType" label="运输方式" width="110">
<template #default="{ row }">{{ transportTypeLabel(row) }}</template>
</el-table-column>
<el-table-column
prop="driverName"
label="司机/船长姓名"
min-width="120"
/><el-table-column
prop="driverPhone"
label="司机/船长手机号"
width="140"
/><el-table-column
prop="departureAddress"
label="发货地址"
min-width="220"
show-overflow-tooltip
/><el-table-column prop="departureContact" label="发货联系人" width="110" /><el-table-column
prop="departurePhone"
label="发货联系人电话"
width="140"
/><el-table-column
prop="arrivalAddress"
label="到货地址"
min-width="220"
show-overflow-tooltip
/><el-table-column prop="arrivalContact" label="收货联系人" width="110" /><el-table-column
prop="arrivalPhone"
label="收货联系人电话"
width="140"
/><el-table-column prop="cargoName" label="货物名称" width="130" /><el-table-column
prop="cargoType"
label="货物类型"
width="120"
/><el-table-column prop="packageType" label="包装" width="100" /><el-table-column
prop="quantity"
label="数量"
width="90"
/><el-table-column prop="quantityUnit" label="数量单位" width="100" /><el-table-column
prop="specification"
label="规格"
width="120"
/><el-table-column prop="model" label="型号" width="120" /><el-table-column
prop="mileage"
label="里程(km)"
width="100"
>
<template #default="{ row }">{{ formatOptionalNumber(row.mileage) }}</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" width="90">
<template #default="{ row }">{{ formatOptionalNumber(row.unitPrice) }}</template>
</el-table-column>
<el-table-column prop="freight" label="运费" width="100">
<template #default="{ row }">{{ formatOptionalNumber(row.freight) }}</template>
</el-table-column>
<el-table-column prop="otherFeeTotal" label="其他费用合计" width="120">
<template #default="{ row }">{{ formatOptionalNumber(row.otherFeeTotal) }}</template>
</el-table-column>
<el-table-column prop="freightTotal" label="运费合计" width="100">
<template #default="{ row }">{{ formatOptionalNumber(row.freightTotal) }}</template>
</el-table-column>
<el-table-column
prop="actualStartDate"
label="实际发货时间"
width="160"
sortable
/><el-table-column
prop="actualEndDate"
label="实际完成时间"
width="160"
sortable
/><el-table-column
prop="planStartDate"
label="预计发货时间"
width="160"
sortable
/><el-table-column
prop="planEndDate"
label="预计完成时间"
width="160"
sortable
/><el-table-column prop="remark" label="备注" min-width="160" /><el-table-column
prop="waybillIdentifier"
label="同一运单标识号"
min-width="140"
/></el-table
>
<div class="waybill-import-toolbar__pagination">
<el-pagination
background
v-model:current-page="detailPage.current"
v-model:page-size="detailPage.size"
layout="total, sizes, prev, pager, next, jumper"
:page-sizes="[10, 20, 50, 100]"
:total="detailPage.total"
@current-change="loadDetails"
@size-change="loadDetails"
/>
</div>
</el-dialog>
<component
:is="createPage ? 'div' : 'el-dialog'"
v-model="createVisible"
title="新建导入"
width="85%"
append-to-body
destroy-on-close
:class="['waybill-import-create', { 'waybill-import-create--page': createPage }]"
>
<section class="waybill-import-create__form-panel">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
class="archive-form"
>
<div class="dialog-section-title">项目信息</div>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="项目" prop="projectId">
<el-select
v-model="form.projectId"
filterable
clearable
placeholder="模糊查询后选择"
@change="projectChange"
>
<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="6">
<el-form-item label="客户" prop="customerName">
<el-input v-model="form.customerName" disabled placeholder="选择客户合同后自动填充" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="客户合同" prop="contractId">
<el-select
v-model="form.contractId"
filterable
clearable
:disabled="!form.projectId"
placeholder="请选择客户合同"
@change="contractChange"
>
<el-option
v-for="item in contracts"
:key="item.id"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="承运类型" prop="carrierType">
<el-select v-model="form.carrierType" @change="carrierTypeChange">
<el-option
v-for="item in carrierTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item
label="承运商"
:prop="isSelfOperated ? 'carrierName' : 'carrierContractId'"
>
<el-select
:model-value="carrierSelectionValue"
filterable
clearable
:disabled="!form.projectId || (isSelfOperated && !form.contractId)"
placeholder="请选择承运商"
@change="carrierChange"
>
<el-option
v-for="item in carrierOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="导入状态" prop="status">
<el-select v-model="form.status">
<el-option
v-for="item in importStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="导入类型" prop="importType">
<el-select v-model="form.importType">
<el-option label="运单" value="waybill" />
<el-option label="结算单" value="settlement" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="计划名称">
<el-select v-model="form.planId" clearable filterable placeholder="请选择">
<el-option
v-for="item in plans"
:key="item.id"
:label="item.planName"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="dialog-section-title waybill-import-create__import-title">导入运单明细</div>
<p v-if="!projectInfoReady" class="waybill-import-create__import-hint">
请先选择项目、客户合同和承运信息,再导入运单明细
</p>
<el-form-item label="运单明细表" prop="file"
><el-upload
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:limit="1"
:disabled="!projectInfoReady"
:file-list="files"
:on-change="fileChange"
:on-remove="fileRemove"
><el-button type="primary" :disabled="!projectInfoReady">导入</el-button></el-upload
><span class="waybill-import-create__file-tip">请上传运单明细表,仅支持 excel 格式;日期支持 2026-08-02、2026-8-2、2026/8/2 等写法</span
><el-link type="primary" @click="downloadTemplate">下载模板</el-link></el-form-item
>
</el-form>
<div v-if="!createPage" class="waybill-import-create__form-actions">
<el-button @click="closeCreate">关闭</el-button
><el-button @click="saveDraft">保存草稿</el-button
><el-button type="primary" @click="confirmImport">确认导入</el-button>
</div>
</section>
<section class="waybill-import-create__detail-panel" v-if="rows.length">
<div class="waybill-import-create__detail-head">
<div class="dialog-section-title">导入数据明细</div>
<el-button 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="440"
:row-class-name="({ row }) => (row._duplicate ? 'is-duplicate-row' : '')"
@selection-change="rowSelection = $event"
><el-table-column type="selection" width="55" /><el-table-column
type="index"
label="序号"
width="65"
/><el-table-column
v-for="column in detailColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth || 150"
show-overflow-tooltip
><template #default="{ row }"
><template v-if="row._editing"
><el-select
v-if="column.editor === 'transportType'"
v-model="row.transportType"
clearable
filterable
placeholder="请选择"
><el-option
v-for="item in transportTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select
><el-select
v-else-if="column.editor === 'driver'"
v-model="row.driverName"
clearable
filterable
placeholder="请选择"
@change="value => handleEditorChange(column, row, value)"
><el-option
v-for="item in driverOptions"
:key="item.id || item.driverName || item.name"
:label="item.driverName || item.name"
:value="item.driverName || item.name" /></el-select
><el-cascader
v-else-if="column.editor === 'cargoType'"
:model-value="resolveCargoTypePath(row)"
:options="cargoTypeOptions"
:props="{ label: 'cargoName', value: 'id', children: 'children', emitPath: true }"
clearable
filterable
placeholder="请选择"
@change="value => handleEditorChange(column, row, value)"
/><el-select
v-else-if="column.editor === 'cargoName'"
v-model="row.cargoName"
clearable
filterable
placeholder="请选择"
@visible-change="visible => visible && loadCommonCargoOptions(row)"
@change="value => handleEditorChange(column, row, value)"
><el-option
v-for="item in getCommonCargoOptions(row)"
:key="item.id || item.cargoName || item.name"
:label="formatCargoTypeLabel(item)"
:value="formatCargoTypeLabel(item)" /></el-select
><el-date-picker
v-else-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 === 'textarea'"
v-model="row[column.prop]"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
:placeholder="`请输入${column.label}`"
/><el-input
v-else-if="column.editor === 'number'"
:model-value="row[column.prop]"
inputmode="decimal"
:placeholder="`请输入${column.label}`"
@input="value => updateEditorValue(column, row, value)"
/><el-input
v-else-if="column.editor"
v-model="row[column.prop]"
:placeholder="`请输入${column.label}`"
/><span v-else>{{ formatCell(row, column) }}</span></template
><span v-else>{{ formatCell(row, column) }}</span></template
></el-table-column
><el-table-column label="操作" width="220" fixed="right"
><template #default="{ row }"
><div class="waybill-import-create__row-actions"
><template v-if="row._editing"
><el-link type="primary" @click="saveRow(row)">保存</el-link
><el-link type="primary" @click="cancelRow(row)">取消</el-link></template
><template v-else
><el-link v-if="row._duplicate" type="primary" @click="confirmDuplicateRow(row)"
>确认</el-link
><el-link type="primary" @click="editRow(row)">编辑</el-link
><el-link type="danger" @click="deleteRow(row)">删除</el-link></template
></div
></template
></el-table-column
></el-table
>
</section>
<div v-if="createPage" class="waybill-import-create__footer">
<el-button @click="closeCreate">取消</el-button>
<el-button @click="saveDraft">保存草稿</el-button>
<el-button type="primary" @click="confirmImport">确认导入</el-button>
</div>
</component>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import dayjs from 'dayjs';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import { getList as getContractList } from '@/api/business/contract-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { getDictionary } from '@/api/system/dictbiz';
import * as api from '@/api/business/waybill-manage';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
import SectionCard from '@/components/section-card/main.vue';
const props = defineProps({ modelValue: Boolean, standalone: Boolean, createPage: Boolean });
const emit = defineEmits(['update:modelValue', 'closed']);
const route = useRoute();
const router = useRouter();
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
const handleClosed = () => emit('closed');
const createVisible = ref(props.createPage),
detailVisible = ref(false),
formRef = ref();
const searchVisible = ref(true);
const columnSettingVisible = ref(false);
const tableSize = ref('default');
const tableSizeOptions = ['default', 'large', 'small'];
const columnOptions = [
{ prop: 'batchNo', label: '运单批次号' },
{ prop: 'projectName', label: '项目名称' },
{ prop: 'carrierName', label: '承运商' },
{ prop: 'carrierType', label: '承运类型' },
{ prop: 'waybillCount', label: '运单数' },
{ prop: 'importTypeName', label: '导入方式' },
{ prop: 'statusName', label: '状态' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateTime', label: '更新时间' },
];
const visibleColumnKeys = ref(columnOptions.map(item => item.prop));
const columnVisible = computed(() =>
Object.fromEntries(columnOptions.map(item => [item.prop, visibleColumnKeys.value.includes(item.prop)]))
);
const toggleTableSize = () => {
const index = tableSizeOptions.indexOf(tableSize.value);
tableSize.value = tableSizeOptions[(index + 1) % tableSizeOptions.length];
};
const query = reactive({ batchNo: '', carrierId: '', createUser: '', createTimeRange: [] });
const detailQuery = reactive({ vehicleNo: '', driverName: '' });
const createDefaultForm = () => ({
id: '',
batchNo: '',
projectId: '',
projectName: '',
customerId: '',
customerName: '',
contractId: '',
contractName: '',
carrierType: '承运商',
carrierId: '',
carrierIds: [],
carrierName: '',
carrierContractId: '',
status: 'completed',
importType: 'waybill',
planId: '',
file: null,
});
const form = reactive(createDefaultForm());
// 导入状态与运单业务状态一致:进行中、已完成。
const importStatusOptions = [
{ label: '进行中', value: 'running' },
{ label: '已完成', value: 'completed' },
];
// 编辑草稿时明细已入库,此时不再强制重新上传附件。
const validateImportFile = (rule, value, callback) =>
form.file || rows.value.length ? callback() : callback(new Error('请上传明细表'));
const rules = {
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
customerName: [{ required: true, message: '请选择客户', trigger: 'change' }],
contractId: [{ required: true, message: '请选择客户合同', trigger: 'change' }],
carrierType: [{ required: true, message: '请选择承运类型', trigger: 'change' }],
carrierName: [{ required: true, message: '请选择承运商', trigger: 'change' }],
carrierContractId: [{ required: true, message: '请选择承运商合同', trigger: 'change' }],
status: [{ required: true, message: '请选择导入状态', trigger: 'change' }],
importType: [{ required: true, message: '请选择导入类型', trigger: 'change' }],
file: [{ required: true, validator: validateImportFile, trigger: 'change' }],
};
// 保存草稿只落库批次与已解析的明细,仅校验后端建批次必填项,不校验附件与明细完整性。
const draftRequiredFields = ['projectId', 'contractId', 'carrierType', 'importType'];
const batches = ref([]),
details = ref([]),
rows = ref([]),
selected = ref([]),
files = ref([]),
projects = ref([]),
contracts = ref([]),
carrierOptions = ref([]),
carrierContracts = ref([]),
queryCarriers = ref([]),
creators = ref([]),
plans = ref([]);
const transportTypeOptions = ref([]),
cargoTypeOptions = ref([]),
cargoTypeFlatOptions = ref([]),
commonCargoOptionsMap = ref({}),
commonCargoLoadingMap = ref({}),
driverOptions = ref([]);
const rowSelection = ref([]),
previewTab = ref('all');
const duplicateRows = computed(() => rows.value.filter(row => row._duplicate));
const previewRows = computed(() =>
previewTab.value === 'duplicate' ? duplicateRows.value : rows.value
);
const carrierContractsLoaded = ref(false),
hasCarrierContracts = ref(null);
const carrierTypes = ['承运商', '自运'];
const isSelfOperated = computed(() => form.carrierType === '自运');
const projectInfoReady = computed(() => {
if (
!form.projectId ||
!String(form.customerName || '').trim() ||
!form.contractId ||
!form.carrierType ||
!form.status ||
!form.importType
) {
return false;
}
return isSelfOperated.value
? Boolean(String(form.carrierName || '').trim())
: Boolean(form.carrierContractId);
});
// 项目下没有承运商合同时只允许自运,与运单管理的承运类型收窄规则一致。
const carrierTypeOptions = computed(() =>
carrierContractsLoaded.value && hasCarrierContracts.value === false ? ['自运'] : carrierTypes
);
const carrierSelectionValue = computed(() =>
isSelfOperated.value ? form.carrierName : form.carrierContractId
);
const page = reactive({ current: 1, size: 10, total: 0 }),
detailPage = reactive({ current: 1, size: 10, total: 0 });
let projectRequestId = 0;
const resetCreateForm = () => {
projectRequestId += 1;
Object.assign(form, createDefaultForm());
files.value = [];
rows.value = [];
rowSelection.value = [];
previewTab.value = 'all';
contracts.value = [];
carrierContracts.value = [];
carrierOptions.value = [];
carrierContractsLoaded.value = false;
hasCarrierContracts.value = null;
};
// 运单批次号由系统自动生成,整批共用一个,明细表内只读展示。
const detailColumns = [
{ prop: 'batchNo', label: '运单批次号', minWidth: 150 },
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 150 },
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 150 },
{ prop: 'vehicleNo', label: '车牌号/船班号/船次号/班次号', editor: 'input', minWidth: 220 },
{ prop: 'transportType', label: '运输方式', editor: 'transportType', minWidth: 150 },
{ prop: 'driverName', label: '司机/船长姓名', editor: 'driver', minWidth: 170 },
{ prop: 'driverPhone', label: '司机/船长手机号', editor: 'input', minWidth: 150 },
{ prop: 'departureAddress', label: '发货地址', editor: 'input', minWidth: 220 },
{ prop: 'departureContact', label: '发货联系人', editor: 'input', minWidth: 120 },
{ prop: 'departurePhone', label: '发货联系人电话', editor: 'input', minWidth: 150 },
{ prop: 'arrivalAddress', label: '到货地址', editor: 'input', minWidth: 220 },
{ prop: 'arrivalContact', label: '收货联系人', editor: 'input', minWidth: 120 },
{ prop: 'arrivalPhone', label: '收货联系人电话', editor: 'input', minWidth: 150 },
{ prop: 'cargoName', label: '货物名称', editor: 'cargoName', minWidth: 220 },
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', minWidth: 220 },
{ prop: 'packageType', label: '包装', editor: 'input', minWidth: 120 },
{ prop: 'quantity', label: '数量', editor: 'number', minWidth: 130 },
{ prop: 'quantityUnit', label: '数量单位', editor: 'input', minWidth: 120 },
{ prop: 'specification', label: '规格', editor: 'input', minWidth: 150 },
{ prop: 'model', label: '型号', editor: 'input', minWidth: 150 },
{ prop: 'mileage', label: '里程(km)', editor: 'number', minWidth: 120 },
{ prop: 'unitPrice', label: '单价', editor: 'number', minWidth: 120 },
{ prop: 'freight', label: '运费', editor: 'number', minWidth: 120 },
{ prop: 'otherFeeTotal', label: '其他费用合计', editor: 'number', minWidth: 140 },
{ prop: 'freightTotal', label: '运费合计', editor: 'number', minWidth: 120 },
{ prop: 'actualStartDate', label: '实际发货时间', editor: 'date', minWidth: 160 },
{ prop: 'actualEndDate', label: '实际完成时间', editor: 'date', minWidth: 160 },
{ prop: 'planStartDate', label: '预计发货时间', editor: 'date', minWidth: 160 },
{ prop: 'planEndDate', label: '预计完成时间', editor: 'date', minWidth: 160 },
{ prop: 'remark', label: '备注', editor: 'textarea', minWidth: 200 },
{ prop: 'waybillIdentifier', label: '同一运单标识号', editor: 'input', minWidth: 150 },
];
const requiredDetailFields = [
{ prop: 'vehicleNo', label: '车牌号/船班号/船次号/班次号' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'departureAddress', label: '发货地址' },
{ prop: 'arrivalAddress', label: '到货地址' },
{ prop: 'cargoName', label: '货物名称' },
{ prop: 'cargoType', label: '货物类型' },
{ prop: 'quantity', label: '数量' },
{ prop: 'quantityUnit', label: '数量单位' },
{ prop: 'actualStartDate', label: '实际发货时间' },
{ prop: 'actualEndDate', label: '实际完成时间' },
];
const projectQueryParams = { approvalStatuses: 'approved,change_approved' };
const extractRecords = res => {
const data = res?.data || res;
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 [];
};
// 批次号由后端按 PC + 导入日期 + 当日流水生成,已删除数据也计入流水,避免唯一索引冲突。
const generateBatchNo = async () => {
const res = await api.getImportBatchNextCode();
return res?.data?.data || '';
};
// 承运商合同的承运商固定取合同乙方,与运单管理(waybill-manage-page)保持一致。
const getCarrierContractPartyName = (contract = {}) =>
String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
contract.secondParty ||
contract.secondPartyName ||
''
).trim();
const buildCarrierContractOptions = contractRows => {
const validContracts = contractRows.filter(
item => String(item.contractCategory || '').trim() === '承运商合同'
);
const nameCounts = validContracts.reduce((result, item) => {
const name = getCarrierContractPartyName(item);
if (name) result[name] = (result[name] || 0) + 1;
return result;
}, {});
return validContracts
.map(contract => {
const carrierName = getCarrierContractPartyName(contract);
if (!contract.id || !carrierName) return null;
const identifier = contract.contractName || contract.contractNo || contract.id;
return {
id:
contract.partyBId ||
contract.partyBUserId ||
contract.contractPartyBId ||
contract.customerContractPartyBId ||
contract.secondPartyId ||
contract.secondPartyUserId ||
contract.carrierId ||
'',
carrierContractId: contract.id,
carrierName,
fullName: carrierName,
label: nameCounts[carrierName] > 1 ? `${carrierName}(${identifier})` : carrierName,
value: contract.id,
};
})
.filter(Boolean);
};
const clearCarrier = () => {
form.carrierId = '';
form.carrierIds = [];
form.carrierName = '';
form.carrierContractId = '';
};
const syncCarrierOptions = () => {
clearCarrier();
if (isSelfOperated.value) {
const customerContract = contracts.value.find(
item => String(item.id) === String(form.contractId)
);
const carrierName = getCarrierContractPartyName(customerContract || {});
if (!carrierName) {
carrierOptions.value = [];
return;
}
const carrierId = resolveCarrierArchiveId(
customerContract?.partyBId ||
customerContract?.partyBUserId ||
customerContract?.contractPartyBId ||
customerContract?.customerId ||
'',
carrierName
);
carrierOptions.value = [{ id: carrierId, carrierName, label: carrierName, value: carrierName }];
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
form.carrierName = carrierName;
return;
}
carrierOptions.value = carrierContracts.value;
};
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 getCargoTypePathLabels = path => {
let options = cargoTypeOptions.value;
const labels = [];
(path || []).forEach(value => {
const option = (options || []).find(item => String(item.id) === String(value));
if (!option) return;
labels.push(option.cargoName || '');
options = option.children || [];
});
return labels.filter(Boolean);
};
const findCargoTypeByPath = path => {
const id = Array.isArray(path) ? path[path.length - 1] : path;
return cargoTypeFlatOptions.value.find(item => String(item.id) === String(id));
};
const resolveCargoTypePath = row => {
if (Array.isArray(row.cargoTypePath) && row.cargoTypePath.length) return row.cargoTypePath;
const cargoType = cargoTypeFlatOptions.value.find(item =>
[item.cargoName, item.name, item.typeName, item.cargoCode].some(
value => String(value || '') === String(row.cargoType || row.cargoTypeCode || '')
)
);
return cargoType?.path || [];
};
const commonCargoKey = row => {
const path = resolveCargoTypePath(row);
const cargoType = findCargoTypeByPath(path);
return String(cargoType?.cargoCode || cargoType?.code || path.join('/'));
};
const getCommonCargoOptions = row => commonCargoOptionsMap.value[commonCargoKey(row)] || [];
const loadCommonCargoOptions = async row => {
const path = resolveCargoTypePath(row);
const cargoType = findCargoTypeByPath(path);
const key = commonCargoKey(row);
if (!key || commonCargoLoadingMap.value[key]) return;
commonCargoLoadingMap.value = { ...commonCargoLoadingMap.value, [key]: true };
try {
const labels = getCargoTypePathLabels(path);
const res = await getCommonCargoList(1, 500, {
secondCargoTypeName: labels[1] || row.cargoType || '',
secondCargoTypeCode: cargoType?.cargoCode || cargoType?.code || '',
allDept: 0,
});
commonCargoOptionsMap.value = { ...commonCargoOptionsMap.value, [key]: extractRecords(res) };
} finally {
commonCargoLoadingMap.value = { ...commonCargoLoadingMap.value, [key]: false };
}
};
const TRANSPORT_TYPE_DEFAULTS = [
{ label: '公路运输', value: 'road', dictKey: 'road', dictValue: '公路运输' },
{ label: '铁路运输', value: 'railway', dictKey: 'railway', dictValue: '铁路运输' },
{ label: '水路运输', value: 'river', dictKey: 'river', dictValue: '水路运输' },
{ label: '航空运输', value: 'air', dictKey: 'air', dictValue: '航空运输' },
];
const readDictionaryList = res => {
const payload = res?.data?.data ?? res?.data ?? res;
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.records)) return payload.records;
if (Array.isArray(payload?.data)) return payload.data;
return [];
};
const normalizeTransportTypeOptions = list => {
const options = [];
const append = item => {
if (!item || typeof item !== 'object') return;
const dictKey = String(item.dictKey ?? item.value ?? '').trim();
const dictValue = String(item.dictValue ?? item.label ?? '').trim();
if (dictKey === '-1') {
(item.children || []).forEach(append);
return;
}
if (dictKey || dictValue) {
options.push({
label: dictValue || dictKey,
value: dictKey || dictValue,
dictKey,
dictValue,
});
}
(item.children || []).forEach(append);
};
(list || []).forEach(append);
return options.length ? options : TRANSPORT_TYPE_DEFAULTS;
};
const applyTransportTypeOptions = res => {
transportTypeOptions.value = normalizeTransportTypeOptions(readDictionaryList(res));
};
const loadEditorOptions = async () => {
const [dictRes, cargoTypeRes, driverRes] = await Promise.all([
getDictionary({ code: 'transport_type' }),
getCargoTypeList(1, 9999),
getDriverList(1, 9999, {}),
]);
applyTransportTypeOptions(dictRes);
cargoTypeOptions.value = buildCargoTypeTree(extractRecords(cargoTypeRes));
cargoTypeFlatOptions.value = flattenCargoTypeOptions(cargoTypeOptions.value);
driverOptions.value = extractRecords(driverRes);
};
const ensureTransportTypeOptions = async () => {
if (transportTypeOptions.value.length) return;
try {
applyTransportTypeOptions(await getDictionary({ code: 'transport_type' }));
} catch (error) {
transportTypeOptions.value = TRANSPORT_TYPE_DEFAULTS;
}
};
const transportTypeLabel = value => {
const source = value && typeof value === 'object' ? value : null;
const stored = source ? source.transportType : value;
const named = String(source?.transportTypeName || '').trim();
const text = String(stored ?? '').trim();
if (named && named !== text) return named;
if (!text && !named) return '-';
const target = (named || text).toLowerCase();
const matched = transportTypeOptions.value.find(item =>
[item.value, item.dictKey, item.label, item.dictValue].some(
candidate => String(candidate ?? '').trim().toLowerCase() === target
)
);
return matched?.dictValue || matched?.label || named || text || '-';
};
const buildBatchQueryParams = () => ({
batchNo: query.batchNo || undefined,
carrierId: query.carrierId || undefined,
createUser: query.createUser || undefined,
createTimeStart: query.createTimeRange?.[0] || undefined,
createTimeEnd: query.createTimeRange?.[1] || undefined,
current: page.current,
size: page.size,
});
const loadListOptions = async () => {
const res = await api.getImportOptions?.();
const data = res?.data?.data || {};
queryCarriers.value = data.carriers || [];
creators.value = data.creators || [];
};
const loadBatches = async () => {
const res = await api.getImportBatches(buildBatchQueryParams());
batches.value = res.data?.data?.records || [];
page.total = res.data?.data?.total || 0;
};
onMounted(() => {
if (props.standalone) {
loadListOptions();
loadBatches();
}
if (props.createPage) openCreatePage();
});
watch(
() => props.modelValue,
value => {
if (value) {
loadListOptions();
loadBatches();
}
}
);
const resetQuery = () => {
Object.assign(query, { batchNo: '', carrierId: '', createUser: '', createTimeRange: [] });
page.current = 1;
loadBatches();
};
const loadDetails = async () => {
const res = await api.getImportDetails({
batchId: detailQuery.batchId,
...detailQuery,
current: detailPage.current,
size: detailPage.size,
});
details.value = (res.data?.data?.records || []).map(normalizeDetailRow);
detailPage.total = res.data?.data?.total || 0;
};
const openCreate = () => prepareCreateForm(true);
const prepareCreateForm = async generateCode => {
resetCreateForm();
createVisible.value = true;
const requests = [
getProjectList(1, 9999, projectQueryParams),
loadEditorOptions(),
api.getImportOptions?.(),
];
if (generateCode) requests.push(generateBatchNo());
const [projectRes, , optionRes, batchNo] = await Promise.all(requests);
if (generateCode) form.batchNo = batchNo || '';
projects.value = projectRes.data?.data?.records || [];
const data = optionRes?.data?.data || {};
queryCarriers.value = data.carriers || [];
creators.value = data.creators || [];
plans.value = data.plans || [];
};
const handleCreate = () => {
if (props.standalone) {
router.push({ path: '/business/waybill-import/form' });
return;
}
openCreate();
};
const closeCreate = () => {
if (props.createPage) {
router.push({ path: '/business/waybill-import' });
return;
}
createVisible.value = false;
};
const openDetail = async row => {
detailQuery.batchId = row.id;
detailQuery.vehicleNo = '';
detailQuery.driverName = '';
detailPage.current = 1;
detailVisible.value = true;
await ensureTransportTypeOptions();
loadDetails();
};
// 草稿明细已落库为草稿运单,编辑时回填到明细表,避免重新上传附件。
const loadBatchRows = async batchId => {
if (!batchId) return;
const res = await api.getImportDetails({ batchId, current: 1, size: 9999 });
rows.value = extractRecords(res).map(({ id, ...item }, index) => ({
...normalizeDetailRow(item),
_key: `${id || 'row'}-${index}`,
batchNo: item.batchNo || form.batchNo,
}));
refreshDuplicateFlags();
rowSelection.value = [];
previewTab.value = 'all';
};
const editBatch = row => {
router.push({
path: '/business/waybill-import/form',
query: { id: row.id, batchNo: row.batchNo || '' },
});
};
const openCreatePage = async () => {
const batchId = route.query.id;
if (!batchId) {
await openCreate();
return;
}
const res = await api.getImportBatches({
id: batchId,
batchNo: route.query.batchNo || '',
current: 1,
size: 20,
});
const batch = (res.data?.data?.records || []).find(item => String(item.id) === String(batchId));
if (!batch) {
ElMessage.warning('未找到导入批次');
router.push({ path: '/business/waybill-import' });
return;
}
await fillEditForm(batch);
};
const fillEditForm = async row => {
await prepareCreateForm(false);
const snapshot = { ...row };
const savedCarrierId = String(snapshot.carrierIds || snapshot.carrierId || '')
.split(',')
.map(item => item.trim())
.find(Boolean);
if (snapshot.projectId) await projectChange(snapshot.projectId);
const project = projects.value.find(item => String(item.id) === String(snapshot.projectId));
Object.assign(form, snapshot, {
id: snapshot.id || '',
batchNo: snapshot.batchNo || '',
projectId: project?.id || snapshot.projectId || '',
projectName: snapshot.projectName || form.projectName,
// 列表返回的是 importStatus,表单沿用 status 字段提交给后端。
status:
snapshot.importStatus === 'running' || snapshot.importStatus === 'completed'
? snapshot.importStatus
: 'completed',
file: null,
carrierIds: [],
});
const plan = plans.value.find(item => String(item.id) === String(snapshot.planId));
// 接口数字 null 会序列化为 -1,未选计划时回填为空
form.planId =
plan && Number(plan.id) !== -1 && Number(plan.id) > 0
? plan.id
: normalizeOptionalPlanId(snapshot.planId) || '';
const contract = contracts.value.find(item => String(item.id) === String(snapshot.contractId));
form.contractId = contract?.id || '';
form.carrierType = snapshot.carrierType || '承运商';
contractChange(form.contractId);
syncCarrierOptions();
const carrierOption =
form.carrierType === '自运'
? null
: carrierOptions.value.find(item => String(item.id) === String(savedCarrierId || '')) ||
carrierOptions.value.find(item => item.carrierName === snapshot.carrierName);
const carrierValue =
form.carrierType === '自运'
? snapshot.carrierName
: snapshot.carrierContractId || carrierOption?.value;
if (carrierValue) carrierChange(carrierValue);
await loadBatchRows(snapshot.id);
};
const removeBatches = () =>
api.removeImportBatches(selected.value.map(item => item.id).join(',')).then(loadBatches);
const removeBatch = row => api.removeImportBatches(row.id).then(loadBatches);
const projectChange = async id => {
const requestId = ++projectRequestId;
const item = projects.value.find(row => String(row.id) === String(id));
form.projectName = item?.projectName || '';
form.customerId = '';
form.customerName = '';
form.contractId = '';
form.contractName = '';
contracts.value = [];
carrierContracts.value = [];
carrierOptions.value = [];
carrierContractsLoaded.value = false;
hasCarrierContracts.value = null;
clearCarrier();
if (!id) return;
const [customerContractRes, carrierContractRes] = await Promise.all([
getContractList(1, 9999, {
projectId: id,
projectName: item?.projectName,
contractCategory: '客户合同',
}),
getContractList(1, 9999, {
projectId: id,
projectName: item?.projectName,
contractCategory: '承运商合同',
}),
]);
if (requestId !== projectRequestId) return;
contracts.value = extractRecords(customerContractRes).filter(
contract => String(contract.contractCategory || '').trim() === '客户合同'
);
carrierContracts.value = buildCarrierContractOptions(extractRecords(carrierContractRes));
carrierContractsLoaded.value = true;
hasCarrierContracts.value = carrierContracts.value.length > 0;
if (!carrierContracts.value.length) form.carrierType = '自运';
if (contracts.value.length) {
form.contractId = contracts.value[0].id;
contractChange(form.contractId);
}
syncCarrierOptions();
};
const contractChange = id => {
const item = contracts.value.find(row => String(row.id) === String(id));
form.customerId = item?.partyAId || item?.customerId || '';
form.customerName = String(item?.partyA || '').trim();
form.contractName = item?.contractName || '';
if (isSelfOperated.value) syncCarrierOptions();
};
const carrierTypeChange = () => {
syncCarrierOptions();
};
const resolveCarrierArchiveId = (carrierId, carrierName) => {
if (carrierId) return carrierId;
const name = String(carrierName || '').trim();
if (!name) return '';
const matched = queryCarriers.value.find(item => String(item.name || '').trim() === name);
return matched?.id || '';
};
const carrierChange = value => {
const item = carrierOptions.value.find(option => String(option.value) === String(value || ''));
clearCarrier();
if (!item) return;
const carrierId = resolveCarrierArchiveId(item.id, item.carrierName);
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
form.carrierName = item.carrierName || '';
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
};
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
const OPTIONAL_NUMBER_EMPTY_SENTINELS = [-1];
const OPTIONAL_NUMBER_PROPS = ['mileage', 'unitPrice', 'freight', 'otherFeeTotal', 'freightTotal'];
const normalizeOptionalImportNumber = (value, emptySentinels = OPTIONAL_NUMBER_EMPTY_SENTINELS) => {
if (!String(value ?? '').trim()) return null;
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
};
const formatOptionalNumber = value => {
const normalized = normalizeOptionalImportNumber(value);
return normalized === null || normalized === undefined ? '' : normalized;
};
const normalizeDetailRow = row => {
const next = { ...(row || {}) };
OPTIONAL_NUMBER_PROPS.forEach(prop => {
next[prop] = formatOptionalNumber(next[prop]);
});
return next;
};
const normalizeImportRow = row => ({
...row,
mileage: normalizeOptionalImportNumber(
firstNotEmpty(row.mileage, row['里程'], row['里程(km)'], row['里程(公里)'])
),
otherFeeTotal: normalizeOptionalImportNumber(
firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
),
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
transportType: firstNotEmpty(
row.transportType,
row['运输类型'],
row['*运输类型'],
row['运输方式'],
row['*运输方式']
),
});
const fileChange = async (file, list) => {
if (!projectInfoReady.value) {
ElMessage.warning('请先选择项目、客户合同和承运信息,再导入运单明细');
files.value = [];
form.file = null;
return;
}
if (file.raw?.size > 50 * 1024 * 1024) {
ElMessage.warning('单个文件大小不能超过50M');
files.value = [];
form.file = null;
return;
}
files.value = list.slice(-1);
form.file = file.raw;
const XLSX = await import('xlsx');
const workbook = XLSX.read(await file.raw.arrayBuffer(), { type: 'array', cellDates: true });
const source = XLSX.utils
.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { defval: '' })
.map(item =>
Object.fromEntries(
Object.entries(item).map(([key, value]) => [key.replace(/^\*/, ''), value])
)
);
const keyMap = {
originalNo: ['原始单号'],
loadingIdentifier: ['配载标识号'],
vehicleNo: ['车牌号/船班号/船次号/班次号', '车牌号/航班号/船号/班列号'],
transportType: ['运输方式', '运输类型'],
driverName: ['司机/船长姓名', '司机/船长'],
driverPhone: ['司机/船长手机号'],
departureAddress: ['发货地址'],
departureContact: ['发货联系人'],
departurePhone: ['发货联系人电话'],
arrivalAddress: ['到货地址'],
arrivalContact: ['收货联系人', '到货联系人'],
arrivalPhone: ['收货联系人电话'],
cargoName: ['货物名称'],
cargoType: ['货物类型'],
packageType: ['包装'],
quantity: ['数量', '重量'],
quantityUnit: ['数量单位'],
specification: ['规格'],
model: ['型号'],
mileage: ['里程(km)', '里程'],
unitPrice: ['单价'],
freight: ['运费'],
otherFeeTotal: ['其他费用合计'],
freightTotal: ['运费合计'],
actualStartDate: ['实际发货时间', '开始时间'],
actualEndDate: ['实际完成时间', '结束时间'],
planStartDate: ['预计发货时间'],
planEndDate: ['预计完成时间'],
remark: ['备注'],
waybillIdentifier: ['同一运单标识号'],
};
const normalizeImportDate = value => {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
const pad = number => String(number).padStart(2, '0');
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
}
const text = String(value ?? '').trim();
return text.slice(0, 10);
};
// 先解析数据,但不填充到 rows.value
const parsedRows = source.map((item, index) => {
const row = { _key: `${Date.now()}-${index}`, ...item, batchNo: form.batchNo };
Object.entries(keyMap).forEach(([key, labels]) => {
row[key] =
labels.map(label => item[label]).find(value => String(value ?? '').trim()) ??
item[key] ??
'';
});
['mileage', 'unitPrice', 'freight', 'otherFeeTotal', 'freightTotal'].forEach(prop => {
row[prop] = normalizeOptionalImportNumber(row[prop]);
});
row.actualStartDate = normalizeImportDate(row.actualStartDate);
row.actualEndDate = normalizeImportDate(row.actualEndDate);
row.planStartDate = normalizeImportDate(row.planStartDate);
row.planEndDate = normalizeImportDate(row.planEndDate);
return row;
});
refreshDuplicateFlags(parsedRows);
// 调用后端校验接口,校验通过才填充表格
const isValid = await performValidation(parsedRows);
if (isValid) {
// 校验通过,填充表格数据
rows.value = parsedRows;
} else {
// 校验失败,清空数据
rows.value = [];
files.value = [];
form.file = null;
}
};
const fileRemove = () => {
files.value = [];
form.file = null;
};
/** 运单批量导入每次请求最多提交行数,避免网关 body 超限。 */
const IMPORT_BATCH_SIZE = 100;
const importFieldText = value => String(value ?? '').trim();
/** 同一运单批次号下,全部导入字段一致时标记为疑似重复。已在线确认的行不再标为重复。 */
const refreshDuplicateFlags = (list = rows.value) => {
const groups = new Map();
list.forEach(row => {
const signature = detailColumns.map(column => importFieldText(row[column.prop])).join('\u0001');
const batchNo = importFieldText(row.batchNo || form.batchNo);
if (row._duplicateConfirmed && row._duplicateSignature !== signature) {
row._duplicateConfirmed = false;
}
row._duplicateSignature = signature;
const groupKey = `${batchNo}\u0002${signature}`;
const group = groups.get(groupKey) || [];
group.push(row);
groups.set(groupKey, group);
});
groups.forEach(group => {
const duplicated = group.length > 1;
group.forEach(row => {
row._duplicate = duplicated && !row._duplicateConfirmed;
});
});
};
const confirmDuplicateRow = row => {
row._duplicateConfirmed = true;
refreshDuplicateFlags();
ElMessage.success('已确认,该行将按正常数据导入');
};
const stripInternalRowFields = row => {
const { _key, _duplicate, _duplicateKey, _duplicateConfirmed, _duplicateSignature, _editing, _editSnapshot, ...data } = row;
return data;
};
/**
* 按配载标识号 / 同一运单标识号尽量同组,再按 IMPORT_BATCH_SIZE 切分。
* 返回 [{ rows, globalIndexes }],globalIndexes 与 rows 一一对应。
*/
const chunkImportRows = sourceRows => {
if (!sourceRows?.length) return [];
const groups = [];
const groupIndexByKey = new Map();
sourceRows.forEach((row, globalIndex) => {
const loadingIdentifier = String(row.loadingIdentifier || '').trim();
const waybillIdentifier = String(row.waybillIdentifier || '').trim();
let groupKey = '';
if (loadingIdentifier) groupKey = `L:${loadingIdentifier}`;
else if (waybillIdentifier) groupKey = `W:${waybillIdentifier}`;
else groupKey = `R:${globalIndex}`;
let groupPos = groupIndexByKey.get(groupKey);
if (groupPos == null) {
groupPos = groups.length;
groupIndexByKey.set(groupKey, groupPos);
groups.push({ rows: [], globalIndexes: [] });
}
groups[groupPos].rows.push(row);
groups[groupPos].globalIndexes.push(globalIndex);
});
const chunks = [];
let current = { rows: [], globalIndexes: [] };
const flush = () => {
if (!current.rows.length) return;
chunks.push(current);
current = { rows: [], globalIndexes: [] };
};
groups.forEach(group => {
// 单组超过上限时整组单独成批,保证跨行一致性校验与多货物合并完整。
if (group.rows.length > IMPORT_BATCH_SIZE) {
flush();
chunks.push(group);
return;
}
if (current.rows.length + group.rows.length > IMPORT_BATCH_SIZE) {
flush();
}
current.rows.push(...group.rows);
current.globalIndexes.push(...group.globalIndexes);
});
flush();
return chunks;
};
const downloadImportFailures = async failureRows => {
if (!failureRows.length) return;
const response = await api.exportImportFailures({
rows: failureRows.map(row => stripInternalRowFields(row)),
});
const blob = response.data || response;
downloadXls(blob, `运单导入失败明细_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`);
};
/** 按每批 100 条请求校验,全部批次结束后再汇总失败行。 */
const collectImportValidationErrors = async rowList => {
const errorMap = new Map();
for (const chunk of chunkImportRows(rowList)) {
const response = await api.validateImport(buildImportPayload(form.status, chunk.rows));
const result = response?.data?.data ?? response?.data ?? response;
if (result?.success === false || (Array.isArray(result?.errors) && result.errors.length)) {
(result.errors || []).forEach(error => {
const localIndex = Number(error.rowIndex);
if (Number.isNaN(localIndex) || localIndex < 0 || localIndex >= chunk.globalIndexes.length) {
return;
}
errorMap.set(chunk.globalIndexes[localIndex], error.errorMessage || '校验失败');
});
}
}
return errorMap;
};
const exportCollectedImportFailures = async (rowList, errorMap, exportAll = false) => {
const indexedRows = exportAll
? rowList.map((row, globalIndex) => [globalIndex, errorMap.get(globalIndex) || ''])
: [...errorMap.entries()].sort((left, right) => left[0] - right[0]);
const failureRows = indexedRows.map(([globalIndex, errorMessage]) => ({
...stripInternalRowFields(rowList[globalIndex]),
errorMessage,
}));
await downloadImportFailures(failureRows);
ElMessage.warning('数据校验失败,已自动下载错误明细表,请修正后重新上传');
};
const performValidation = async parsedRows => {
if (!parsedRows || !parsedRows.length) return false;
try {
const chunks = chunkImportRows(parsedRows);
const errorMap = new Map();
for (const chunk of chunks) {
const payload = {
...form,
rows: chunk.rows.map(stripInternalRowFields),
};
const response = await api.validateImport(payload);
const result = response?.data?.data ?? response?.data ?? response;
if (result?.success === false || (Array.isArray(result?.errors) && result.errors.length)) {
(result.errors || []).forEach(error => {
const localIndex = Number(error.rowIndex);
if (Number.isNaN(localIndex) || localIndex < 0 || localIndex >= chunk.globalIndexes.length) {
return;
}
const globalIndex = chunk.globalIndexes[localIndex];
errorMap.set(globalIndex, error.errorMessage || '校验失败');
});
}
}
if (errorMap.size) {
await exportCollectedImportFailures(parsedRows, errorMap);
return false;
}
ElMessage.success('数据校验通过');
return true;
} catch (error) {
console.error('校验失败:', error);
ElMessage.error(error?.message || '校验接口调用失败');
return false;
}
};
const formatCell = (row, column) =>
column.prop === 'transportType' ? transportTypeLabel(row) : row[column.prop] || '';
const updateEditorValue = (column, row, value) => {
if (column.editor === 'number') {
const [integer = '', decimal = ''] = String(value || '')
.replace(/[^\d.]/g, '')
.split('.');
row[column.prop] =
decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
return;
}
row[column.prop] = value;
};
const handleEditorChange = (column, row, value) => {
if (column.editor === 'driver') {
const driver = driverOptions.value.find(item =>
[item.driverName, item.name].some(name => String(name || '') === String(value || ''))
);
row.driverName = value || '';
if (driver)
row.driverPhone =
driver.mobile || driver.phone || driver.driverPhone || row.driverPhone || '';
}
if (column.editor === 'cargoType') {
const path = Array.isArray(value) ? value : [];
const cargoType = findCargoTypeByPath(path);
const labels = getCargoTypePathLabels(path);
row.cargoTypePath = path;
row.cargoType = labels.at(-1) || '';
row.cargoTypeCode = cargoType?.cargoCode || cargoType?.code || cargoType?.id || '';
row.cargoName = '';
row.specification = '';
row.model = '';
loadCommonCargoOptions(row);
}
if (column.editor === 'cargoName') {
const cargo = getCommonCargoOptions(row).find(item => formatCargoTypeLabel(item) === value);
row.cargoName = value || '';
if (cargo) {
row.specification = cargo.specification || cargo.spec || row.specification || '';
row.model = cargo.model || cargo.modelName || row.model || '';
row.packageType = cargo.packageType || row.packageType || '';
row.brand = cargo.brand || row.brand || '';
}
}
};
const downloadTemplate = async () => {
try {
const res = await api.exportImportTemplate();
downloadXls(res.data, '运单批量导入模板.xlsx');
ElMessage.success('模板下载成功');
} catch (error) {
ElMessage.error('模板下载失败');
}
};
const isValidMobile = value => /^\d{11}$/.test(String(value || '').trim());
const validateImportRows = () => {
if (!rows.value.length) {
ElMessage.warning('请上传至少一条运单明细');
return false;
}
for (const [index, row] of rows.value.entries()) {
const missingField = requiredDetailFields.find(field => !String(row[field.prop] ?? '').trim());
if (missingField) {
ElMessage.warning(`第${index + 1}行${missingField.label}不能为空`);
return false;
}
const driverPhone = String(row.driverPhone ?? '').trim();
if (driverPhone && !isValidMobile(driverPhone)) {
ElMessage.warning(`第${index + 1}行司机/船长手机号格式不正确,应为11位数字`);
return false;
}
}
return true;
};
const editRow = row => {
row._editSnapshot = JSON.parse(JSON.stringify(row));
row._editing = true;
};
const saveRow = 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 normalizeOptionalPlanId = value => {
if (value === undefined || value === null || value === '') return null;
return Number(value) === -1 || Number(value) <= 0 ? null : value;
};
const buildImportPayload = (status = form.status, rowList = rows.value, extra = {}) => {
const { file, ...batch } = form;
const planId = normalizeOptionalPlanId(batch.planId);
const carrierId = resolveCarrierArchiveId(
batch.carrierId || batch.carrierIds?.[0] || '',
batch.carrierName
);
const carrierIds =
Array.isArray(batch.carrierIds) && batch.carrierIds.length
? batch.carrierIds
: carrierId
? [carrierId]
: [];
return {
...batch,
planId,
// 未选计划时不带占位名称,避免落库/回显 -1
planName: planId ? batch.planName || undefined : null,
carrierId: carrierId || undefined,
carrierIds,
status,
rows: rowList.map(normalizeImportRow).map(stripInternalRowFields),
...extra,
};
};
// 独立页面下返回批次列表路由,弹窗形态下关闭新建面板并刷新列表。
const backToList = () => {
closeCreate();
if (!props.createPage) loadBatches();
};
// 保存草稿:批次与已解析明细一并入库,成功后返回批次列表。
const saveDraft = async () => {
await formRef.value?.validateField(draftRequiredFields);
const chunks = chunkImportRows(rows.value);
if (!chunks.length) {
ElMessage.warning('请上传至少一条运单明细');
return;
}
// 草稿编辑时首批带原 id 覆盖;后续批 append,避免整包超限。
let batchId = form.id || '';
for (let i = 0; i < chunks.length; i++) {
const append = i > 0;
const payload = buildImportPayload('draft', chunks[i].rows, {
id: append ? batchId : batchId || undefined,
append,
});
const response = await api.saveImportDraft(payload);
const batch = response?.data?.data ?? response?.data ?? response;
batchId = batch?.id || batchId;
}
if (batchId) form.id = batchId;
ElMessage.success('保存草稿成功');
backToList();
};
const confirmImport = async () => {
await formRef.value?.validate();
if (!validateImportRows()) return;
const count = rows.value.filter(row => row._duplicate).length;
if (count) await ElMessageBox.confirm(`当前导入有${count}条疑似重复数据,是否确认导入?`, '提示');
if (form.importType === 'settlement' && form.status === 'completed')
await ElMessageBox.confirm('确认后将同时生成运单、应收应付明细、计结算单,是否继续?', '提示');
const chunks = chunkImportRows(rows.value);
let errorMap;
try {
errorMap = await collectImportValidationErrors(rows.value);
} catch (error) {
console.error('校验失败:', error);
ElMessage.error(error?.message || '校验接口调用失败');
return;
}
if (errorMap.size) {
await exportCollectedImportFailures(rows.value, errorMap, true);
return;
}
// 校验全部通过后才分批落库,避免前面批次已入库、后面批次校验失败。
const draftBatchId = form.id || '';
let batchId = '';
try {
for (let i = 0; i < chunks.length; i++) {
const append = i > 0;
const payload = buildImportPayload(form.status, chunks[i].rows, {
id: append ? batchId : draftBatchId || undefined,
append,
});
const response = await api.confirmImport(payload);
const batch = response?.data?.data ?? response?.data ?? response;
batchId = batch?.id || batchId;
}
ElMessage.success('导入成功');
backToList();
} catch (error) {
console.error('确认导入失败:', error);
ElMessage.error(
batchId
? `部分数据可能已导入(批次已创建),请到批次详情核对后重试:${error?.message || '导入失败'}`
: error?.message || '导入失败'
);
}
};
</script>
<style scoped lang="scss">
.waybill-import-toolbar {
&__search {
padding: 18px 18px 8px;
margin-bottom: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
&__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
&__search :deep(.el-form-item) {
margin-bottom: 8px;
}
&__search :deep(.el-form-item__label) {
white-space: nowrap;
}
&__search :deep(.el-input),
&__search :deep(.el-select),
&__search :deep(.el-date-editor),
&__search :deep(.el-date-editor.el-input),
&__search :deep(.el-date-editor.el-input__wrapper),
&__search :deep(.el-date-editor--datetimerange) {
width: 100%;
}
&__search-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
}
&__actions {
margin-top: 12px;
background: transparent;
}
&__column-setting {
display: flex;
flex-direction: column;
gap: 8px;
}
&__table-panel {
padding: 0 12px 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
&__pagination {
display: flex;
justify-content: flex-end;
width: 100%;
box-sizing: border-box;
padding: 16px 0;
}
&__table-panel :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
&__table-panel :deep(.el-table__body tr:nth-child(even) > td.el-table__cell),
&__table-panel :deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--left),
&__table-panel :deep(.el-table__body tr:nth-child(even) > td.el-table-fixed-column--right) {
background: #fafafa;
}
}
.waybill-import-page {
min-height: calc(100vh - 120px);
}
.waybill-import-create {
&__form-panel,
&__detail-panel {
border: 1px solid #eff1f7;
padding: 20px 24px;
}
&__detail-panel {
margin-top: 12px;
}
&__import-title {
margin-top: 8px;
}
&__import-hint {
margin: 0 0 12px;
color: #909399;
font-size: 13px;
line-height: 20px;
}
&__form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
&__detail-head {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 8px;
}
&__row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.is-duplicate-row > td.el-table__cell) {
background: #fff7e8;
}
&__file-tip {
margin: 0 24px;
color: #606266;
}
:deep(.el-table .el-input),
:deep(.el-table .el-select),
:deep(.el-table .el-cascader),
:deep(.el-table .el-date-editor) {
width: 100%;
}
&__footer {
position: fixed;
z-index: 20;
right: 0;
bottom: 0;
left: 230px;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 64px;
box-sizing: border-box;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
// 统一 12px 间距,与全站底部操作栏(去 gap,由相邻按钮 margin 提供)一致
.el-button + .el-button {
margin-left: 12px;
}
}
&--page {
min-height: calc(100vh - 120px);
padding: 12px 24px 96px;
background: #fff;
}
}
:global(.avue--collapse .waybill-import-create__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .waybill-import-create__footer) {
left: 0;
}
.waybill-search .el-form-item{
margin-bottom: 0 !important;
}
</style>