Files
tms-erp-web/src/views/business/transport-plan-import.vue
T
b2894lxlx cba2ad3b3b 1、调整凭证
2、调整运单
2026-09-08 17:50:34 +08:00

672 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<basic-container class="transport-plan-import-page">
<div class="transport-plan-import-page__form-wrapper">
<div class="transport-plan-import-page__title">导入运输计划</div>
<el-form
ref="importFormRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
class="transport-plan-import-page__form"
>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="项目" prop="projectId" required>
<el-select
v-model="form.projectId"
placeholder="模糊查询后选择"
filterable
clearable
:loading="projectLoading"
@visible-change="visible => visible && loadProjectOptions()"
@change="handleProjectChange"
>
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.projectName"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户" prop="customerName" required>
<el-input v-model="form.customerName" disabled placeholder="选择项目后带出" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户合同" prop="contractId" required>
<el-select
v-model="form.contractId"
placeholder="选择项目后选择"
filterable
clearable
:loading="contractLoading"
:disabled="!form.projectId"
@change="handleContractChange"
>
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item
label="计划明细表"
prop="file"
required
class="transport-plan-import-page__file-item"
>
<el-upload
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:limit="1"
:file-list="files"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-button type="primary">添加附件</el-button>
</el-upload>
<span class="transport-plan-import-page__file-tip">
请上传计划明细表,仅支持 Excel 格式
</span>
<el-link type="primary" @click="handleTemplateDownload">下载模板</el-link>
</el-form-item>
</el-form>
</div>
<section v-if="rows.length" class="transport-plan-import-page__preview">
<div class="transport-plan-import-page__preview-head">
<div class="dialog-section-title">数据明细</div>
<el-button type="danger" plain :disabled="!selection.length" @click="removeSelectedRows">
批量删除
</el-button>
</div>
<el-tabs v-model="activeTab" type="border-card" class="transport-plan-import-page__tabs">
<el-tab-pane :label="`计划单数(${rows.length}`" name="all" />
<el-tab-pane :label="`疑似重复(${duplicateCount})`" name="duplicate" />
</el-tabs>
<div class="transport-plan-import-page__table">
<el-table
:data="visibleRows"
border
row-key="_key"
empty-text="暂无明细数据"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="52" fixed="left" />
<el-table-column type="index" label="序号" width="70" fixed="left" />
<el-table-column label="计划单号" min-width="150">
<template #default>待生成</template>
</el-table-column>
<el-table-column
v-for="column in previewColumns"
:key="column.prop"
:label="column.label"
:min-width="column.width"
>
<template #default="{ row }">
<el-select
v-if="isEditingRow(row) && column.prop === 'quantityUnit'"
v-model="row[column.prop]"
>
<el-option
v-for="item in quantityUnitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<el-input
v-else-if="isEditingRow(row)"
v-model="row[column.prop]"
:maxlength="column.prop === 'remark' ? 200 : undefined"
:show-word-limit="column.prop === 'remark'"
/>
<span v-else>{{ row[column.prop] || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<div class="transport-plan-import-page__actions">
<template v-if="isEditingRow(row)">
<el-link type="primary" @click="saveRow">保存</el-link>
<el-link type="primary" @click="cancelEditRow(row)">取消</el-link>
</template>
<template v-else>
<el-link type="primary" @click="editRow(row)">编辑</el-link>
<el-link type="danger" @click="removeRow(row)">删除</el-link>
</template>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
<div class="transport-plan-import-page__footer">
<el-button :disabled="loading" @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">提交</el-button>
</div>
</basic-container>
</template>
<script>
import { exportBlob } from '@/api/common';
import { getList as getContractList } from '@/api/business/contract-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { importTransportPlan } from '@/api/business/transport-plan';
import { config } from '@/option/business/transport-plan';
import { downloadXls } from '@/utils/util';
const importColumns = [
['计划名称', 'planName'],
['运输类型', 'transportType'],
['发货地址', 'departureAddress'],
['发货联系人', 'departureContact'],
['发货联系人电话', 'departurePhone'],
['到货地址', 'arrivalAddress'],
['收货联系人', 'arrivalContact'],
['收货联系人电话', 'arrivalPhone'],
['货物名称', 'cargoName'],
['货物类型', 'cargoType'],
['数量', 'quantity'],
['计量单位', 'quantityUnit'],
['包装', 'packageType'],
['规格', 'specification'],
['型号', 'model'],
['里程(km)', 'mileage'],
['计划开始时间', 'planStartDate'],
['计划结束时间', 'planEndDate'],
['备注', 'remark'],
['同一计划标识号', 'planGroupId'],
];
const previewColumns = [
{ label: '计划名称', prop: 'planName', width: 180 },
{ label: '运输类型', prop: 'transportType', width: 120 },
{ label: '发货地址', prop: 'departureAddress', width: 260 },
{ label: '发货联系人', prop: 'departureContact', width: 120 },
{ label: '发货联系人电话', prop: 'departurePhone', width: 140 },
{ label: '到货地址', prop: 'arrivalAddress', width: 260 },
{ label: '收货联系人', prop: 'arrivalContact', width: 120 },
{ label: '收货联系人电话', prop: 'arrivalPhone', width: 140 },
{ label: '货物名称', prop: 'cargoName', width: 140 },
{ label: '货物类型', prop: 'cargoType', width: 120 },
{ label: '数量', prop: 'quantity', width: 100 },
{ label: '计量单位', prop: 'quantityUnit', width: 110 },
{ label: '包装', prop: 'packageType', width: 100 },
{ label: '规格', prop: 'specification', width: 120 },
{ label: '型号', prop: 'model', width: 120 },
{ label: '里程(km)', prop: 'mileage', width: 120 },
{ label: '计划开始时间', prop: 'planStartDate', width: 140 },
{ label: '计划结束时间', prop: 'planEndDate', width: 140 },
{ label: '备注', prop: 'remark', width: 180 },
{ label: '同一计划标识号', prop: 'planGroupId', width: 150 },
];
const extractRecords = res => {
const data = res?.data?.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 [];
};
const defaultForm = () => ({
projectId: '',
projectName: '',
customerName: '',
contractId: '',
contractName: '',
file: null,
});
export default {
name: 'TransportPlanImport',
data() {
return {
form: defaultForm(),
rules: {
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
customerName: [{ required: true, message: '请选择项目后带出客户', trigger: 'change' }],
contractId: [{ required: true, message: '请选择项目后带出客户合同', trigger: 'change' }],
file: [{ required: true, message: '请上传计划明细表', trigger: 'change' }],
},
projectOptions: [],
projectLoading: false,
contractOptions: [],
contractLoading: false,
files: [],
sourceFile: null,
rows: [],
selection: [],
activeTab: 'all',
editingKey: '',
editSnapshot: null,
loading: false,
previewColumns,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
};
},
computed: {
duplicateCount() {
return this.rows.filter(row => row._duplicate).length;
},
visibleRows() {
return this.activeTab === 'duplicate' ? this.rows.filter(row => row._duplicate) : this.rows;
},
},
created() {
this.loadProjectOptions();
},
methods: {
loadProjectOptions() {
if (this.projectLoading) return;
this.projectLoading = true;
getProjectList(1, 9999, config.projectQueryParams || {})
.then(res => {
this.projectOptions = extractRecords(res);
})
.finally(() => {
this.projectLoading = false;
});
},
handleProjectChange(projectId) {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
this.form.customerName =
project?.customerName || project?.customerNames || project?.customer || '';
this.form.projectName = project?.projectName || '';
this.form.contractId = '';
this.form.contractName = '';
this.contractOptions = [];
if (!projectId) return;
this.contractLoading = true;
getContractList(1, 9999, {
projectId,
projectName: project?.projectName,
...(config.contractQueryParams || {}),
})
.then(res => {
this.contractOptions = extractRecords(res);
const contract = this.contractOptions[0];
this.form.customerName =
this.form.customerName || contract?.customerName || contract?.partyA || '';
this.form.contractId = contract?.id || '';
this.form.contractName = contract?.contractName || '';
})
.finally(() => {
this.contractLoading = false;
});
},
handleContractChange(contractId) {
const contract = this.contractOptions.find(item => String(item.id) === String(contractId));
this.form.contractName = contract?.contractName || '';
this.form.customerName =
contract?.customerName || contract?.partyA || this.form.customerName || '';
},
async handleFileChange(file, fileList) {
if (!/\.(xls|xlsx)$/i.test(file.name || '')) {
this.$message.error('请上传 .xls 或 .xlsx 格式文件');
this.files = [];
this.form.file = null;
return;
}
try {
const XLSX = await import('xlsx');
const workbook = XLSX.read(await file.raw.arrayBuffer(), {
type: 'array',
cellDates: false,
});
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const values = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
raw: false,
blankrows: false,
});
const headers = values[0] || [];
const headerMap = headers.reduce((result, label, index) => {
const normalized = String(label || '')
.replace(/^\*/, '')
.trim();
if (normalized) result[normalized] = index;
return result;
}, {});
const rows = values.slice(1).reduce((result, cells, index) => {
const row = { _key: `${Date.now()}-${index}-${Math.random().toString(36).slice(2)}` };
importColumns.forEach(([label, prop]) => {
row[prop] = String(cells[headerMap[label]] ?? '').trim();
});
if (importColumns.some(([, prop]) => row[prop])) result.push(row);
return result;
}, []);
if (!rows.length) {
this.$message.warning('未读取到计划明细数据');
this.handleFileRemove();
return;
}
this.files = fileList.slice(-1);
this.sourceFile = file.raw;
this.form.file = file.raw;
this.rows = rows;
this.markDuplicates();
this.$refs.importFormRef?.validateField('file');
this.$message.success(`已读取${rows.length}条计划明细`);
} catch (error) {
this.handleFileRemove();
this.$message.error('计划明细表读取失败,请检查文件格式');
}
},
handleFileRemove() {
this.files = [];
this.sourceFile = null;
this.form.file = null;
this.rows = [];
this.selection = [];
this.editingKey = '';
this.editSnapshot = null;
},
duplicateKey(row) {
return importColumns.map(([, prop]) => String(row[prop] || '').trim()).join('\u0001');
},
markDuplicates() {
const countMap = this.rows.reduce((result, row) => {
const key = this.duplicateKey(row);
result[key] = (result[key] || 0) + 1;
return result;
}, {});
this.rows.forEach(row => {
row._duplicate = countMap[this.duplicateKey(row)] > 1;
});
},
handleSelectionChange(rows) {
this.selection = rows;
},
isEditingRow(row) {
return this.editingKey === row._key;
},
editRow(row) {
if (this.editingKey && this.editingKey !== row._key) {
this.$message.warning('请先保存或取消当前编辑');
return;
}
this.editingKey = row._key;
this.editSnapshot = { ...row };
},
saveRow() {
this.editingKey = '';
this.editSnapshot = null;
this.markDuplicates();
},
cancelEditRow(row) {
Object.assign(row, this.editSnapshot || {});
this.editingKey = '';
this.editSnapshot = null;
this.markDuplicates();
},
removeRow(row) {
this.rows = this.rows.filter(item => item._key !== row._key);
this.selection = this.selection.filter(item => item._key !== row._key);
this.markDuplicates();
},
removeSelectedRows() {
const selectedKeys = new Set(this.selection.map(row => row._key));
this.rows = this.rows.filter(row => !selectedKeys.has(row._key));
this.selection = [];
this.markDuplicates();
},
validateRows() {
if (!this.rows.length) {
this.$message.warning('请保留至少一条计划明细');
return false;
}
const requiredFields = [
['planName', '计划名称'],
['transportType', '运输类型'],
['departureAddress', '发货地址'],
['arrivalAddress', '到货地址'],
['cargoType', '货物类型'],
];
const isValidDate = value => {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return false;
const [, year, month, day] = match;
const date = new Date(Number(year), Number(month) - 1, Number(day));
return (
date.getFullYear() === Number(year) &&
date.getMonth() === Number(month) - 1 &&
date.getDate() === Number(day)
);
};
for (const [index, row] of this.rows.entries()) {
const emptyField = requiredFields.find(([prop]) => !String(row[prop] || '').trim());
if (emptyField) {
this.$message.warning(`第${index + 1}行${emptyField[1]}不能为空`);
return false;
}
if (row.planStartDate && !isValidDate(row.planStartDate)) {
this.$message.warning(`第${index + 1}行计划开始时间格式必须为 YYYY-MM-DD`);
return false;
}
if (row.planEndDate && !isValidDate(row.planEndDate)) {
this.$message.warning(`第${index + 1}行计划结束时间格式必须为 YYYY-MM-DD`);
return false;
}
if (row.planStartDate && row.planEndDate && row.planEndDate < row.planStartDate) {
this.$message.warning(`第${index + 1}行计划结束时间不能早于计划开始时间`);
return false;
}
if (
row.quantity !== '' &&
(!Number.isFinite(Number(row.quantity)) || Number(row.quantity) < 0)
) {
this.$message.warning(`第${index + 1}行数量必须为非负数`);
return false;
}
if (
row.mileage !== '' &&
(!Number.isFinite(Number(row.mileage)) || Number(row.mileage) < 0)
) {
this.$message.warning(`第${index + 1}行里程必须为非负数`);
return false;
}
if (String(row.remark || '').length > 200) {
this.$message.warning(`第${index + 1}行备注不能超过200个字`);
return false;
}
}
return true;
},
async buildImportFile() {
const XLSX = await import('xlsx');
const requiredLabels = [
'计划名称',
'运输类型',
'发货地址',
'到货地址',
'货物类型',
];
const values = [
importColumns.map(([label]) => (requiredLabels.includes(label) ? `*${label}` : label)),
...this.rows.map(row => importColumns.map(([, prop]) => String(row[prop] ?? ''))),
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(values), '运输计划导入模板');
const binary = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const fileName = (this.sourceFile?.name || '运输计划导入模板.xlsx').replace(
/\.xls$/i,
'.xlsx'
);
return new File([binary], fileName, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
},
handleTemplateDownload() {
exportBlob(config.transportPlanTemplateUrl, {}, { feedback: true }).then(res => {
downloadXls(res.data, '运输计划导入模板.xlsx');
});
},
async handleSubmit() {
const valid = await this.$refs.importFormRef?.validate().catch(() => false);
if (!valid || this.loading || !this.validateRows()) return;
this.loading = true;
try {
const file = await this.buildImportFile();
const res = await importTransportPlan({ ...this.form, file });
const contentType = res.headers?.['content-type'] || res.data?.type || '';
if (
contentType.includes('application/vnd.ms-excel') ||
contentType.includes('spreadsheetml')
) {
downloadXls(
res.data,
`运输计划导入失败明细${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
this.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const text = await res.data.text();
const result = text ? JSON.parse(text) : {};
if (result.code !== 200) throw new Error(result.msg || '导入失败');
this.$message.success('导入完成');
}
this.$router.push('/business/transport-plan');
} catch (error) {
this.$message.error(error.message || '导入失败');
} finally {
this.loading = false;
}
},
handleCancel() {
this.$router.push('/business/transport-plan');
},
},
};
</script>
<style lang="scss" scoped>
.transport-plan-import-page {
min-height: calc(100vh - 112px);
&__form-wrapper {
background: #ffffff;
border-radius: 8px;
margin-bottom: 16px;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
padding: 18px 24px;
border-bottom: 1px solid #eff1f7;
color: #303133;
font-size: 18px;
font-weight: 600;
&::before {
width: 4px;
height: 18px;
background: #409eff;
content: '';
}
}
&__form {
padding: 24px 24px 4px;
.el-select,
.el-input {
width: 100%;
}
:deep(.el-form-item) {
margin-bottom: 16px;
}
}
&__file-item {
margin-top: 12px;
:deep(.el-form-item__content) {
gap: 16px;
}
}
&__file-tip {
color: #606266;
}
&__preview {
margin: 8px 24px 0;
padding-top: 16px;
border-top: 1px solid #eff1f7;
}
&__preview-head {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 12px;
.dialog-section-title {
margin-bottom: 0;
}
}
&__tabs {
margin-bottom: 12px;
}
&__table {
overflow-x: auto;
:deep(.el-table) {
min-width: 2400px;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
:deep(.el-table__row:nth-child(even) td) {
background: #fafafa;
}
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__footer {
position: fixed;
right: 0;
left: 230px;
bottom: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 12px 24px;
border-top: 1px solid #eff1f7;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
}
:global(.avue--collapse .transport-plan-import-page__footer) {
left: 60px;
}
</style>