1621 lines
58 KiB
Vue
1621 lines
58 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.createUserName"
|
||
prop="createUserName"
|
||
label="创建人"
|
||
min-width="120"
|
||
>
|
||
<template #default="{ row }">{{ row.createUserName || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-if="columnVisible.statusName"
|
||
prop="statusName"
|
||
label="状态"
|
||
width="100"
|
||
/>
|
||
<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>
|
||
<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
|
||
>
|
||
<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"
|
||
/><el-table-column
|
||
prop="vehicleNo"
|
||
label="车牌号/航班号/船号/班列号"
|
||
min-width="190"
|
||
/><el-table-column prop="transportType" label="运输方式" width="110">
|
||
<template #default="{ row }">{{ transportTypeLabel(row.transportType) }}</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"
|
||
/><el-table-column prop="unitPrice" label="单价" width="90" /><el-table-column
|
||
prop="freight"
|
||
label="运费"
|
||
width="100"
|
||
/><el-table-column prop="otherFeeTotal" label="其他费用合计" width="120" /><el-table-column
|
||
prop="freightTotal"
|
||
label="运费合计"
|
||
width="100"
|
||
/><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"
|
||
>
|
||
<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="item.contractName"
|
||
: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>
|
||
<el-form-item label="运单明细表" prop="file"
|
||
><el-upload
|
||
action="#"
|
||
accept=".xls,.xlsx"
|
||
:auto-upload="false"
|
||
:limit="1"
|
||
:file-list="files"
|
||
:on-change="fileChange"
|
||
:on-remove="fileRemove"
|
||
><el-button type="primary">添加附件</el-button></el-upload
|
||
><span class="waybill-import-create__file-tip">请上传运单明细表,仅支持 excel 格式</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"
|
||
@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="180" fixed="right"
|
||
><template #default="{ row }"
|
||
><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 type="primary" @click="editRow(row)">编辑</el-link
|
||
><el-link type="danger" @click="deleteRow(row)">删除</el-link></template
|
||
></template
|
||
></el-table-column
|
||
></el-table
|
||
>
|
||
</section>
|
||
<div v-if="createPage" class="waybill-import-create__footer">
|
||
<el-button @click="closeCreate">取消</el-button>
|
||
<el-button type="primary" @click="confirmImport">确认导入</el-button>
|
||
</div>
|
||
</component>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||
import { 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 { downloadXls } from '@/utils/util';
|
||
|
||
const props = defineProps({ modelValue: Boolean, standalone: Boolean, createPage: Boolean });
|
||
const emit = defineEmits(['update:modelValue', 'closed']);
|
||
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: 'createUserName', label: '创建人' },
|
||
{ prop: 'statusName', 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());
|
||
// 批量导入状态仅保留草稿与导入完成两种,与后端 importStatus 取值一致。
|
||
const importStatusOptions = [
|
||
{ label: '进行中', value: 'draft' },
|
||
{ 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 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 =
|
||
customerContract?.partyBId ||
|
||
customerContract?.partyBUserId ||
|
||
customerContract?.customerId ||
|
||
'';
|
||
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 loadEditorOptions = async () => {
|
||
const [dictRes, cargoTypeRes, driverRes] = await Promise.all([
|
||
getDictionary({ code: 'transport_type' }),
|
||
getCargoTypeList(1, 9999),
|
||
getDriverList(1, 9999, {}),
|
||
]);
|
||
transportTypeOptions.value = extractRecords(dictRes).map(item => ({
|
||
label: item.dictValue || item.label,
|
||
value: item.dictKey || item.value,
|
||
}));
|
||
cargoTypeOptions.value = buildCargoTypeTree(extractRecords(cargoTypeRes));
|
||
cargoTypeFlatOptions.value = flattenCargoTypeOptions(cargoTypeOptions.value);
|
||
driverOptions.value = extractRecords(driverRes);
|
||
};
|
||
const ensureTransportTypeOptions = async () => {
|
||
if (transportTypeOptions.value.length) return;
|
||
const dictRes = await getDictionary({ code: 'transport_type' });
|
||
transportTypeOptions.value = extractRecords(dictRes).map(item => ({
|
||
label: item.dictValue || item.label,
|
||
value: item.dictKey || item.value,
|
||
}));
|
||
};
|
||
const transportTypeLabel = value => {
|
||
if (value === null || value === undefined || value === '') return '-';
|
||
return (
|
||
transportTypeOptions.value.find(item => String(item.value) === String(value))?.label || value
|
||
);
|
||
};
|
||
const loadBatches = async () => {
|
||
const res = await api.getImportBatches({ ...query, current: page.current, size: page.size });
|
||
batches.value = res.data?.data?.records || [];
|
||
page.total = res.data?.data?.total || 0;
|
||
};
|
||
onMounted(() => {
|
||
if (props.standalone) loadBatches();
|
||
if (props.createPage) openCreate();
|
||
});
|
||
watch(
|
||
() => props.modelValue,
|
||
value => {
|
||
if (value) 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 || [];
|
||
detailPage.total = res.data?.data?.total || 0;
|
||
};
|
||
const openCreate = async () => {
|
||
resetCreateForm();
|
||
createVisible.value = true;
|
||
const [projectRes, , optionRes, batchNo] = await Promise.all([
|
||
getProjectList(1, 9999, projectQueryParams),
|
||
loadEditorOptions(),
|
||
api.getImportOptions?.(),
|
||
generateBatchNo(),
|
||
]);
|
||
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) => ({
|
||
...item,
|
||
_key: `${id || 'row'}-${index}`,
|
||
batchNo: item.batchNo || form.batchNo,
|
||
}));
|
||
rowSelection.value = [];
|
||
previewTab.value = 'all';
|
||
};
|
||
const editBatch = async row => {
|
||
await openCreate();
|
||
const snapshot = { ...row };
|
||
if (snapshot.projectId) await projectChange(snapshot.projectId);
|
||
Object.assign(form, snapshot, {
|
||
projectId: snapshot.projectId || '',
|
||
projectName: snapshot.projectName || form.projectName,
|
||
// 列表返回的是 importStatus,表单沿用 status 字段提交给后端。
|
||
status: snapshot.importStatus || 'completed',
|
||
file: null,
|
||
});
|
||
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 carrierValue =
|
||
form.carrierType === '自运' ? snapshot.carrierName : snapshot.carrierContractId;
|
||
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 carrierChange = value => {
|
||
const item = carrierOptions.value.find(option => String(option.value) === String(value || ''));
|
||
clearCarrier();
|
||
if (!item) return;
|
||
form.carrierId = item.id || '';
|
||
form.carrierIds = item.id ? [item.id] : [];
|
||
form.carrierName = item.carrierName || '';
|
||
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
|
||
};
|
||
const fileChange = async (file, list) => {
|
||
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);
|
||
};
|
||
const count = new Map();
|
||
// 先解析数据,但不填充到 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] ??
|
||
'';
|
||
});
|
||
row.actualStartDate = normalizeImportDate(row.actualStartDate);
|
||
row.actualEndDate = normalizeImportDate(row.actualEndDate);
|
||
row.planStartDate = normalizeImportDate(row.planStartDate);
|
||
row.planEndDate = normalizeImportDate(row.planEndDate);
|
||
const duplicateKey = JSON.stringify(Object.keys(keyMap).map(key => row[key]));
|
||
count.set(duplicateKey, (count.get(duplicateKey) || 0) + 1);
|
||
row._duplicateKey = duplicateKey;
|
||
return row;
|
||
});
|
||
parsedRows.forEach(row => {
|
||
row._duplicate = count.get(row._duplicateKey) > 1;
|
||
});
|
||
|
||
// 调用后端校验接口,校验通过才填充表格
|
||
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;
|
||
};
|
||
|
||
const performValidation = async (parsedRows) => {
|
||
if (!parsedRows || !parsedRows.length) return false;
|
||
|
||
try {
|
||
// 构建校验请求参数
|
||
const payload = {
|
||
...form,
|
||
rows: parsedRows.map(row => {
|
||
const { _key, _duplicate, _duplicateKey, _editing, _editSnapshot, ...data } = row;
|
||
return data;
|
||
})
|
||
};
|
||
|
||
// 调用后端校验接口
|
||
const response = await api.validateImport(payload);
|
||
|
||
// 检查响应类型
|
||
const blob = response.data || response;
|
||
|
||
// 如果是 Blob,需要检查其 MIME 类型
|
||
if (blob instanceof Blob) {
|
||
// Excel 文件的 MIME 类型
|
||
if (blob.type.includes('application/vnd.ms-excel') ||
|
||
blob.type.includes('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')) {
|
||
// 校验失败,下载错误明细
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `运单导入失败明细_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`;
|
||
link.click();
|
||
window.URL.revokeObjectURL(url);
|
||
ElMessage.warning('数据校验失败,已自动下载错误明细表,请修正后重新上传');
|
||
return false;
|
||
} else if (blob.type.includes('application/json')) {
|
||
// 可能是 JSON 响应被当作 Blob,需要解析
|
||
const text = await blob.text();
|
||
const json = JSON.parse(text);
|
||
if (json.success) {
|
||
ElMessage.success('数据校验通过');
|
||
return true;
|
||
} else {
|
||
ElMessage.error(json.msg || '校验失败');
|
||
return false;
|
||
}
|
||
} else {
|
||
// 其他类型,默认认为是校验通过
|
||
ElMessage.success('数据校验通过');
|
||
return true;
|
||
}
|
||
} else {
|
||
// 不是 Blob,直接判断
|
||
ElMessage.success('数据校验通过');
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
console.error('校验失败:', error);
|
||
ElMessage.error('校验接口调用失败');
|
||
return false;
|
||
}
|
||
};
|
||
const formatCell = (row, column) => 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 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;
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
const editRow = row => {
|
||
row._editSnapshot = JSON.parse(JSON.stringify(row));
|
||
row._editing = true;
|
||
};
|
||
const saveRow = row => {
|
||
row._editing = false;
|
||
delete row._editSnapshot;
|
||
};
|
||
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);
|
||
};
|
||
const removeSelectedRows = () => {
|
||
const keys = new Set(rowSelection.value.map(row => row._key));
|
||
rows.value = rows.value.filter(row => !keys.has(row._key));
|
||
rowSelection.value = [];
|
||
};
|
||
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
|
||
const normalizeOptionalImportNumber = (value, emptySentinels = []) => {
|
||
if (!String(value ?? '').trim()) return null;
|
||
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
|
||
};
|
||
const normalizeImportRow = row => ({
|
||
...row,
|
||
mileage: normalizeOptionalImportNumber(
|
||
firstNotEmpty(row.mileage, row['里程'], row['里程(km)'], row['里程(公里)']),
|
||
[-1]
|
||
),
|
||
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 buildImportPayload = (status = form.status) => {
|
||
const { file, ...batch } = form;
|
||
return { ...batch, status, rows: rows.value.map(normalizeImportRow) };
|
||
};
|
||
// 独立页面下返回批次列表路由,弹窗形态下关闭新建面板并刷新列表。
|
||
const backToList = () => {
|
||
closeCreate();
|
||
if (!props.createPage) loadBatches();
|
||
};
|
||
// 保存草稿:批次与已解析明细一并入库,成功后返回批次列表。
|
||
const saveDraft = async () => {
|
||
await formRef.value?.validateField(draftRequiredFields);
|
||
await api.saveImportDraft(buildImportPayload('draft'));
|
||
ElMessage.success('保存草稿成功');
|
||
backToList();
|
||
};
|
||
const confirmImport = async () => {
|
||
// 导入状态选择草稿时不生成正式运单,等同于保存草稿。
|
||
if (form.status === 'draft') return saveDraft();
|
||
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')
|
||
await ElMessageBox.confirm('确认后将同时生成运单、应收应付明细、计结算单,是否继续?', '提示');
|
||
await api.confirmImport(buildImportPayload());
|
||
ElMessage.success('导入成功');
|
||
backToList();
|
||
};
|
||
</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;
|
||
}
|
||
&__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;
|
||
}
|
||
&__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;
|
||
}
|
||
</style>
|