74780b8777
Co-authored-by: Cursor <cursoragent@cursor.com>
2021 lines
73 KiB
Vue
2021 lines
73 KiB
Vue
<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>
|