1、调整凭证

2、调整运单
This commit is contained in:
2026-09-08 17:50:34 +08:00
parent a7315b4491
commit cba2ad3b3b
43 changed files with 5730 additions and 1119 deletions
@@ -0,0 +1,671 @@
<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>