From 6d4961f40462124f9e55727d3f9ae626dab41dc7 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 9 Sep 2026 10:21:19 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E8=B0=83=E6=95=B4=E5=87=AD=E8=AF=81?= =?UTF-8?q?=202=E3=80=81=E8=B0=83=E6=95=B4=E5=AF=B9=E8=B4=A6=203=E3=80=81?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=AD=A3=E5=BC=8F=E7=BB=93=E7=AE=97=E5=86=B2?= =?UTF-8?q?=E7=AA=81bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/business/transport-plan.js | 24 ++++++ src/api/business/voucher-manage.js | 7 ++ src/api/business/waybill-manage.js | 1 + src/api/settlement/transportReconciliation.js | 4 +- .../components/business-crud-page.vue | 5 +- .../components/waybill-import-dialog.vue | 79 ++++++++++++++++++- .../components/waybill-manage-page.vue | 5 +- src/views/business/transport-plan-import.vue | 63 ++++++++++++++- src/views/business/voucher-manage-detail.vue | 55 +++++++++++-- src/views/business/voucher-manage.vue | 21 ++++- .../components/formal-settlement-editor.vue | 3 +- .../transport-reconciliation-editor.vue | 9 ++- 12 files changed, 257 insertions(+), 19 deletions(-) diff --git a/src/api/business/transport-plan.js b/src/api/business/transport-plan.js index 4a1f813..b964d76 100644 --- a/src/api/business/transport-plan.js +++ b/src/api/business/transport-plan.js @@ -35,3 +35,27 @@ export const importTransportPlan = ({ timeout: 60000, }); }; + +export const validateTransportPlan = ({ + file, + projectId, + projectName, + customerName, + contractId, + contractName, +}) => { + const data = new FormData(); + data.append('file', file); + data.append('projectId', projectId || ''); + data.append('projectName', projectName || ''); + data.append('customerName', customerName || ''); + data.append('contractId', contractId || ''); + data.append('contractName', contractName || ''); + return request({ + url: `${baseUrl}/validate-transport-plan`, + method: 'post', + data, + responseType: 'blob', + timeout: 60000, + }); +}; diff --git a/src/api/business/voucher-manage.js b/src/api/business/voucher-manage.js index d5c1a41..b4a3152 100644 --- a/src/api/business/voucher-manage.js +++ b/src/api/business/voucher-manage.js @@ -16,6 +16,13 @@ export const replaceFolder = (voucherId, plateNo, file) => { data.append('file', file); return request({ url: `${baseUrl}/folder-replace`, method: 'post', data }); }; +export const replaceFolderByObject = (voucherId, plateNo, fileInfo) => + request({ + url: `${baseUrl}/folder-replace-object`, + method: 'post', + params: { voucherId, plateNo, ...fileInfo }, + timeout: 120000, + }); export const removeFolder = (voucherId, plateNo) => request({ url: `${baseUrl}/folder-remove`, method: 'post', params: { voucherId, plateNo } }); export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post', data }); diff --git a/src/api/business/waybill-manage.js b/src/api/business/waybill-manage.js index d9ba828..2786ce6 100644 --- a/src/api/business/waybill-manage.js +++ b/src/api/business/waybill-manage.js @@ -41,4 +41,5 @@ export const removeImportBatches = ids => request({ url: `${baseUrl}/import-batc export const getImportOptions = () => request({ url: `${baseUrl}/import-batch/options`, method: 'get' }); export const exportImportTemplate = () => request({ url: `${baseUrl}/import-batch/export-template`, method: 'get', responseType: 'blob' }); export const saveImportDraft = data => request({ url: `${baseUrl}/import-batch/draft`, method: 'post', data }); +export const validateImport = data => request({ url: `${baseUrl}/import-batch/validate`, method: 'post', data, responseType: 'blob' }); export const confirmImport = data => request({ url: `${baseUrl}/import-batch/confirm`, method: 'post', data }); diff --git a/src/api/settlement/transportReconciliation.js b/src/api/settlement/transportReconciliation.js index c56be1e..ac9d0f5 100644 --- a/src/api/settlement/transportReconciliation.js +++ b/src/api/settlement/transportReconciliation.js @@ -23,8 +23,8 @@ export const manualMatch = data => export const unmatch = internalId => request({ url: `${baseUrl}/unmatch`, method: 'post', params: { internalId } }); export const adjust = data => request({ url: `${baseUrl}/adjust`, method: 'post', data }); -export const updateByMatch = id => - request({ url: `${baseUrl}/update-by-match`, method: 'post', params: { id } }); +export const updateByMatch = (id, data) => + request({ url: `${baseUrl}/update-by-match`, method: 'post', params: { id }, data }); export const complete = id => request({ url: `${baseUrl}/complete`, method: 'post', params: { id } }); export const completeWithData = data => diff --git a/src/views/business/components/business-crud-page.vue b/src/views/business/components/business-crud-page.vue index 53af5a3..d56a38e 100644 --- a/src/views/business/components/business-crud-page.vue +++ b/src/views/business/components/business-crud-page.vue @@ -7234,6 +7234,7 @@ export default { .then(res => { const detail = res?.data?.data || res?.data || row; this.detailRow = detail; + this.loadWaybillVoucherImages(); this.ensureTransportPlanDetailTransportTypeName(detail); if (this.isWaybillDetailLayout && detail.contractId) { return getContractDetail(detail.contractId) @@ -7296,7 +7297,8 @@ export default { if (!config?.id) return []; return getProcessConfigDetail(config.id, this.detailRow.id).then(detailRes => { const detail = detailRes?.data?.data || detailRes?.data || config; - this.waybillHasRelatedVoucher = Boolean(detail.hasRelatedVoucher); + this.waybillHasRelatedVoucher = + this.waybillHasRelatedVoucher || Boolean(detail.hasRelatedVoucher); return this.getProcessConfigEnabledNodes(detail); }); }) @@ -7320,6 +7322,7 @@ export default { getProcessConfigVoucherImages(this.detailRow.id) .then(res => { this.waybillVoucherImages = extractRecords(res); + if (this.waybillVoucherImages.length) this.waybillHasRelatedVoucher = true; this.waybillVoucherImagesLoaded = true; }) .finally(() => { diff --git a/src/views/business/components/waybill-import-dialog.vue b/src/views/business/components/waybill-import-dialog.vue index 4dec2af..e15d170 100644 --- a/src/views/business/components/waybill-import-dialog.vue +++ b/src/views/business/components/waybill-import-dialog.vue @@ -1095,7 +1095,8 @@ const fileChange = async (file, list) => { return text.slice(0, 10); }; const count = new Map(); - rows.value = source.map((item, index) => { + // 先解析数据,但不填充到 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] = @@ -1112,14 +1113,88 @@ const fileChange = async (file, list) => { row._duplicateKey = duplicateKey; return row; }); - rows.value.forEach(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') { diff --git a/src/views/business/components/waybill-manage-page.vue b/src/views/business/components/waybill-manage-page.vue index 0024809..8b08153 100644 --- a/src/views/business/components/waybill-manage-page.vue +++ b/src/views/business/components/waybill-manage-page.vue @@ -4127,6 +4127,7 @@ export default { .then(res => { const detail = res?.data?.data || res?.data || row; this.detailRow = detail; + this.loadWaybillVoucherImages(); if (detail.contractId) { return getContractDetail(detail.contractId) .then(contractRes => { @@ -4196,7 +4197,8 @@ export default { if (!config?.id) return []; return getProcessConfigDetail(config.id, this.detailRow.id).then(detailRes => { const detail = detailRes?.data?.data || detailRes?.data || config; - this.waybillHasRelatedVoucher = Boolean(detail.hasRelatedVoucher); + this.waybillHasRelatedVoucher = + this.waybillHasRelatedVoucher || Boolean(detail.hasRelatedVoucher); return this.getProcessConfigEnabledNodes(detail); }); }) @@ -4220,6 +4222,7 @@ export default { getProcessConfigVoucherImages(this.detailRow.id) .then(res => { this.waybillVoucherImages = extractRecords(res); + if (this.waybillVoucherImages.length) this.waybillHasRelatedVoucher = true; this.waybillVoucherImagesLoaded = true; }) .finally(() => { diff --git a/src/views/business/transport-plan-import.vue b/src/views/business/transport-plan-import.vue index 486dc98..de780cc 100644 --- a/src/views/business/transport-plan-import.vue +++ b/src/views/business/transport-plan-import.vue @@ -163,9 +163,10 @@ 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 { importTransportPlan, validateTransportPlan } from '@/api/business/transport-plan'; import { config } from '@/option/business/transport-plan'; import { downloadXls } from '@/utils/util'; +import dayjs from 'dayjs'; const importColumns = [ ['计划名称', 'planName'], @@ -362,6 +363,9 @@ export default { this.markDuplicates(); this.$refs.importFormRef?.validateField('file'); this.$message.success(`已读取${rows.length}条计划明细`); + + // 调用后端校验接口 + await this.performValidation(); } catch (error) { this.handleFileRemove(); this.$message.error('计划明细表读取失败,请检查文件格式'); @@ -376,6 +380,63 @@ export default { this.editingKey = ''; this.editSnapshot = null; }, + async performValidation() { + if (!this.rows.length || !this.form.file) return; + + try { + // 校验表单必填项 + await this.$refs.importFormRef?.validate(); + + const response = await validateTransportPlan({ + file: this.form.file, + projectId: this.form.projectId, + projectName: this.form.projectName, + customerName: this.form.customerName, + contractId: this.form.contractId, + contractName: this.form.contractName, + }); + + // 检查响应类型 + 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); + this.$message.warning('数据校验失败,已自动下载错误明细表,请修正后重新上传'); + } else if (blob.type.includes('application/json')) { + // 可能是 JSON 响应被当作 Blob,需要解析 + const text = await blob.text(); + const json = JSON.parse(text); + if (json.success) { + this.$message.success('数据校验通过'); + } else { + this.$message.error(json.msg || '校验失败'); + } + } else { + // 其他类型,默认认为是校验通过 + this.$message.success('数据校验通过'); + } + } else { + // 不是 Blob,直接判断 + this.$message.success('数据校验通过'); + } + } catch (error) { + console.error('校验失败:', error); + // 表单校验失败时不提示错误,让表单自己显示错误信息 + if (error && error.message && !error.message.includes('validate')) { + this.$message.error('校验接口调用失败'); + } + } + }, duplicateKey(row) { return importColumns.map(([, prop]) => String(row[prop] || '').trim()).join('\u0001'); }, diff --git a/src/views/business/voucher-manage-detail.vue b/src/views/business/voucher-manage-detail.vue index 6da19b2..93851ab 100644 --- a/src/views/business/voucher-manage-detail.vue +++ b/src/views/business/voucher-manage-detail.vue @@ -33,7 +33,7 @@ -
上传

重新上传替换当前未匹配的凭证,支持图片或压缩包,大小不超过50M

+
选择文件

{{ replaceProgress >= 100 ? '文件上传完成,正在处理凭证...' : '重新上传替换当前未匹配的凭证,支持图片或压缩包,大小不超过50M' }}

@@ -42,6 +42,7 @@ import { onMounted, reactive, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { ElMessage, ElMessageBox } from 'element-plus'; +import md5 from 'js-md5'; import * as api from '@/api/business/voucher-manage'; const route = useRoute(); @@ -51,7 +52,7 @@ const loading = ref(false), rows = ref([]), voucher = ref({ voucherBatchNo: rout const page = reactive({ current: 1, size: 10, total: 0 }); const query = reactive({ plateNo: '', matched: undefined }); const viewerVisible = ref(false), viewerLoading = ref(false), viewer = ref({}), viewerFiles = ref([]); -const replaceVisible = ref(false), replacing = ref(false), replaceRow = ref(), replaceFile = ref(); +const replaceVisible = ref(false), replacing = ref(false), replaceRow = ref(), replaceFile = ref(), replaceProgress = ref(0), replaceProgressStatus = ref(''); const load = async () => { loading.value = true; try { @@ -68,14 +69,58 @@ const openViewer = async row => { viewerVisible.value = true; viewerLoading.value = true; viewer.value = row; viewerFiles.value = []; try { const res = await api.getFolderDetail(voucherId, row.plateNo); viewer.value = res.data?.data || row; viewerFiles.value = viewer.value.files || []; } catch (error) { ElMessage.error(error.message || '获取凭证详情失败'); } finally { viewerLoading.value = false; } }; -const openReplace = row => { replaceRow.value = row; replaceFile.value = undefined; replaceVisible.value = true; }; +const openReplace = row => { replaceRow.value = row; replaceFile.value = undefined; replaceProgress.value = 0; replaceProgressStatus.value = ''; replaceVisible.value = true; }; const selectReplace = upload => { replaceFile.value = upload.raw; }; -const clearReplace = () => { replaceFile.value = undefined; }; +const clearReplace = () => { replaceFile.value = undefined; replaceProgress.value = 0; replaceProgressStatus.value = ''; }; +const uploadReplaceFile = async file => { + const chunkSize = 2 * 1024 * 1024; + const digest = md5.base64(await file.arrayBuffer()); + const taskRes = await api.createFileTask({ + md5: digest, + attachmentName: file.name, + size: file.size, + chunkSize, + chunkTotal: Math.ceil(file.size / chunkSize), + force: true, + businessType: 'voucher-replace', + businessId: `${voucherId}:${replaceRow.value.plateNo}`, + }); + const task = taskRes.data?.data; + if (!task?.id || !task.objectKey) throw new Error('创建分片上传任务失败'); + replaceProgress.value = 0; + for (let partNumber = 1; partNumber <= task.chunkTotal; partNumber += 1) { + const urlRes = await api.getPartUploadUrl(task.id, partNumber); + const part = file.slice((partNumber - 1) * chunkSize, Math.min(partNumber * chunkSize, file.size)); + const response = await fetch(urlRes.data?.data, { method: 'PUT', body: part }); + if (!response.ok) throw new Error(`分片${partNumber}上传失败`); + const etag = String(response.headers.get('etag') || '').replaceAll('"', ''); + if (!etag) throw new Error('未获取到分片 ETag'); + await api.updateFileTask({ id: task.id, partNumber, etag }); + replaceProgress.value = Math.round((partNumber / task.chunkTotal) * 100); + } + const completedRes = await api.getFileTask(task.id); + const completedTask = completedRes.data?.data; + if (!completedTask || completedTask.status !== 'completed') throw new Error('文件分片合并未完成'); + return completedTask; +}; const confirmReplace = async () => { if (!replaceFile.value) return ElMessage.warning('请选择凭证文件'); if (replaceFile.value.size > 50 * 1024 * 1024) return ElMessage.warning('凭证文件大小不能超过50M'); replacing.value = true; - try { await api.replaceFolder(voucherId, replaceRow.value.plateNo, replaceFile.value); ElMessage.success('上传成功,已重新匹配运单'); replaceVisible.value = false; await load(); } catch (error) { ElMessage.error(error.message || '上传失败'); } finally { replacing.value = false; } + replaceProgress.value = 0; + replaceProgressStatus.value = ''; + try { + const task = await uploadReplaceFile(replaceFile.value); + await api.replaceFolderByObject(voucherId, replaceRow.value.plateNo, { + objectKey: task.objectKey, + fileName: task.attachmentName || replaceFile.value.name, + size: replaceFile.value.size, + contentType: replaceFile.value.type, + }); + ElMessage.success('上传成功,已重新匹配运单'); + replaceVisible.value = false; + await load(); + } catch (error) { replaceProgressStatus.value = 'exception'; ElMessage.error(error.message || '上传失败'); } finally { replacing.value = false; } }; const removeFolder = row => ElMessageBox.confirm(`确认删除车牌”${row.plateNo}”的凭证吗?`, '提示', { type: 'warning' }).then(async () => { await api.removeFolder(voucherId, row.plateNo); ElMessage.success('删除成功'); load(); }); const goToLoading = loadingNo => { router.push({ path: '/business/loading-manage', query: { loadingNo } }); }; diff --git a/src/views/business/voucher-manage.vue b/src/views/business/voucher-manage.vue index 9523a54..4f865a6 100644 --- a/src/views/business/voucher-manage.vue +++ b/src/views/business/voucher-manage.vue @@ -100,7 +100,7 @@ width="120" fixed="right" align="center" - />