From aadd06302818c1c5b939e165e660582f16409377 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 20:38:12 +0800 Subject: [PATCH] feat(client): surface Excel draft import in order creation --- src/modules/first-leg/api/firstLeg.ts | 7 ++ src/modules/first-leg/routes.ts | 10 +- .../first-leg/store/firstLegOrders.test.mjs | 1 + .../first-leg/utils/firstLegOrder.test.mjs | 6 +- src/modules/first-leg/utils/firstLegOrder.ts | 4 +- .../first-leg/views/BatchImportView.vue | 5 - .../views/FirstLegOrdersView.test.mjs | 12 +- .../first-leg/views/FirstLegOrdersView.vue | 112 +++++++++++++++--- src/navigation/clientNavigation.test.mjs | 2 +- 9 files changed, 118 insertions(+), 41 deletions(-) delete mode 100644 src/modules/first-leg/views/BatchImportView.vue diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index 50d2065..2486560 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -334,6 +334,13 @@ export function downloadMerchantProductImage(fileId: number | string) { return apiBlobRequest({ method: 'GET', url: `/merchant/v1/files/product-images/${fileId}` }) } +export function downloadMerchantFirstLegBatchImportErrorReport(batchNo: string, fileId: number | string) { + return apiBlobRequest({ + method: 'GET', + url: `/merchant/v1/files/order-batch-imports/${encodeURIComponent(batchNo)}/error-reports/${encodeURIComponent(String(fileId))}`, + }) +} + export async function downloadMerchantInboundCartonLabels(inboundId: number | string) { return downloadMerchantCartonLabelPdf( `/merchant/v1/first-leg/inbounds/${encodeURIComponent(String(inboundId))}/carton-label-pdf`, diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts index 96ff88f..f3d3ec5 100644 --- a/src/modules/first-leg/routes.ts +++ b/src/modules/first-leg/routes.ts @@ -1,4 +1,4 @@ -import { FileText, Package, Ship, Zap } from '@lucide/vue' +import { FileText, Ship, Zap } from '@lucide/vue' import type { ClientModuleManifest } from '../types.ts' export const firstLegModule = { @@ -30,14 +30,6 @@ export const firstLegModule = { release: 'active', meta: { title: '编辑订单草稿', description: '继续完善快速下单生成的草稿明细', permission: 'order:first-leg:edit' }, }, - { - path: 'first-leg/import', - name: 'first-leg-import', - component: () => import('./views/BatchImportView.vue'), - release: 'prototype', - meta: { title: '一键导入', description: '批量导入头程订单', permission: 'merchant:first-leg:import' }, - navigation: { key: 'first-leg-import', icon: Package }, - }, { path: 'first-leg/quick-order', name: 'first-leg-quick-order', diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index e082c48..095cb15 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -34,6 +34,7 @@ test('declares the documented merchant warehouse and first-leg endpoints', () => assert.doesNotMatch(apiSource, /merchant\/v1\/order-addresses/) assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch-imports\/template'/) assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch-imports'/) + assert.match(apiSource, /`\/merchant\/v1\/files\/order-batch-imports\/\$\{encodeURIComponent\(batchNo\)\}\/error-reports\/\$\{encodeURIComponent\(String\(fileId\)\)\}`/) assert.match(apiSource, /params: \{ mode \}/) assert.match(apiSource, /data\.append\('file', file\)/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/) diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index 79470be..6ce01cf 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -144,11 +144,11 @@ test('requires customs inspection when any cargo is a wooden product', () => { const draft = validDraft() draft.items[0].woodenProduct = true draft.customsInspection = null - assert.ok(validateFirstLegOrderDraft(draft).includes('木制品必须选择商检')) + assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物为木制品,“是否商检”必须选择“是”')) draft.customsInspection = false - assert.ok(validateFirstLegOrderDraft(draft).includes('木制品必须选择商检')) + assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物为木制品,“是否商检”必须选择“是”')) draft.customsInspection = true - assert.ok(!validateFirstLegOrderDraft(draft).includes('木制品必须选择商检')) + assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('为木制品'))) }) test('summarizes cargo values from line items', () => { diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index 0c8ffd9..631d976 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -132,10 +132,8 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) { if (!Number.isInteger(item.unitsPerPackage) || item.unitsPerPackage <= 0) errors.push(`${line}每箱数量必须为正整数`) if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`) if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`) + if (item.woodenProduct && draft.customsInspection !== true) errors.push(`${line}为木制品,“是否商检”必须选择“是”`) }) - if (draft.items.some((item) => item.woodenProduct) && draft.customsInspection !== true) { - errors.push('木制品必须选择商检') - } return errors } diff --git a/src/modules/first-leg/views/BatchImportView.vue b/src/modules/first-leg/views/BatchImportView.vue deleted file mode 100644 index f24454a..0000000 --- a/src/modules/first-leg/views/BatchImportView.vue +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 1053f4c..23d1c64 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -324,7 +324,8 @@ test('collects the backend-required first-leg fields', () => { } assert.match(source, /form\.channelName = channel\?\.channelName/) assert.match(source, /v-model="item\.woodenProduct"/) - assert.match(source, /货物包含木制品,提交前必须选择“是”/) + assert.match(source, /第 \{\{ woodenCargoLines\.join\('、'\) \}\} 条货物已勾选木制品/) + assert.match(source, /第 \{\{ index \+ 1 \}\} 条须商检/) }) test('distills order creation into the confirmed prototype sections with server pricing', () => { @@ -337,14 +338,21 @@ test('distills order creation into the confirmed prototype sections with server assert.match(source, /class="segmented-options"/) assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/) assert.match(source, //) - assert.match(source, /批量导入订单/) + assert.match(source, /isCreatePage && !routeDraftId && canCreateOrder[\s\S]*Excel 批量导入/) + assert.match(source, /导入成功的数据只创建草稿,不会直接提交审核/) assert.match(source, /下载正式模板[\s\S]*选择 Excel/) assert.match(source, /仅支持 \.xlsx,文件不超过 5MB、最多 50 行/) + assert.match(source, /cargo\.woodenProduct(true\/false)[\s\S]*customsInspection 必须填写 true/) + assert.match(source, /下载 Excel 错误报告/) + assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/) assert.match(source, /添加行[\s\S]*删除所选行/) assert.doesNotMatch(source, /货物明细导入模板\.xls/) for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) { assert.ok(cargoTableSource.includes(heading)) } + assert.ok(cargoTableSource.indexOf('包装类型') < cargoTableSource.indexOf('危险品')) + assert.ok(cargoTableSource.indexOf('危险品') < cargoTableSource.indexOf('货件编号')) + assert.ok(cargoTableSource.indexOf('木制品') < cargoTableSource.indexOf('货件编号')) assert.match(source, /参考运费/) assert.match(source, /仅供参考,最终以实际结算为准/) assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 7baf695..97f4a81 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -19,6 +19,7 @@ import { createIdempotencyKey, createMerchantOrderAddress, disableMerchantOrderAddress, + downloadMerchantFirstLegBatchImportErrorReport, downloadMerchantFirstLegBatchImportTemplate, downloadMerchantOrderCartonLabelsBatch, downloadMerchantProductImage, @@ -101,7 +102,9 @@ const batchImportFile = ref(null) const batchImportIdempotencyKey = ref('') const batchImportSubmitting = ref(false) const batchTemplateDownloading = ref(false) +const batchErrorReportDownloading = ref(false) const batchImportResult = ref(null) +const batchImportError = ref('') const fixedOrderColumns: DataTableColumn[] = [ { key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true }, { key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, @@ -416,6 +419,9 @@ function quickOrderDraft() { const form = reactive(quickOrderDraft()) const serializedDraft = computed(() => JSON.stringify(form)) const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value) +const woodenCargoLines = computed(() => form.items + .map((item, index) => item.woodenProduct ? index + 1 : null) + .filter((index): index is number => index != null)) const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders' useUnsavedWorkRegistration({ @@ -608,6 +614,16 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : '服务暂时不可用' } +function batchImportErrorMessage(error: unknown) { + if (!(error instanceof ApiError)) return errorMessage(error) + return [ + error.message, + error.code ? `错误码 ${error.code}` : '', + error.requestId ? `requestId ${error.requestId}` : '', + error.traceId ? `traceId ${error.traceId}` : '', + ].filter(Boolean).join(' · ') +} + function getOrderCargoSummary(order: FirstLegOrder) { if (order.items.length) return `${order.items[0]?.cargoNameCn || '货物'}${order.items.length > 1 ? `等 ${order.items.length} 项` : ''}` if (order.businessSummary.totalPackageCount != null) return `${order.businessSummary.totalPackageCount} 箱` @@ -1467,6 +1483,7 @@ function openBatchImport() { batchImportFile.value = null batchImportIdempotencyKey.value = '' batchImportResult.value = null + batchImportError.value = '' batchImportOpen.value = true } @@ -1476,52 +1493,83 @@ function selectBatchImportFile(event: Event) { input.value = '' if (!file) return if (!file.name.toLowerCase().endsWith('.xlsx')) { - ElMessage.error('仅支持 .xlsx 格式的订单导入文件') + batchImportError.value = '仅支持 .xlsx 格式的订单导入文件' + ElMessage.error(batchImportError.value) return } if (file.size > 5 * 1024 * 1024) { - ElMessage.error('订单导入文件不能超过 5MB') + batchImportError.value = '订单导入文件不能超过 5MB' + ElMessage.error(batchImportError.value) return } batchImportFile.value = file batchImportIdempotencyKey.value = createIdempotencyKey('first-leg-batch-import') batchImportResult.value = null + batchImportError.value = '' } async function downloadBatchTemplate() { try { batchTemplateDownloading.value = true + batchImportError.value = '' saveBlob(await downloadMerchantFirstLegBatchImportTemplate(), '头程订单批量导入模板.xlsx') } catch (error) { - ElMessage.error(errorMessage(error)) + batchImportError.value = batchImportErrorMessage(error) + ElMessage.error(batchImportError.value) } finally { batchTemplateDownloading.value = false } } +async function downloadBatchErrorReport() { + const result = batchImportResult.value + if (!result?.errorReportFileId) return + try { + batchErrorReportDownloading.value = true + batchImportError.value = '' + saveBlob( + await downloadMerchantFirstLegBatchImportErrorReport(result.batchNo, result.errorReportFileId), + `${result.batchNo}-错误报告.xlsx`, + ) + } catch (error) { + batchImportError.value = batchImportErrorMessage(error) + ElMessage.error(batchImportError.value) + } finally { + batchErrorReportDownloading.value = false + } +} + async function submitBatchImport() { if (!batchImportFile.value) { - ElMessage.warning('请先选择 .xlsx 订单导入文件') + batchImportError.value = '请先选择 .xlsx 订单导入文件' + ElMessage.warning(batchImportError.value) return } try { batchImportSubmitting.value = true + batchImportError.value = '' batchImportResult.value = await importMerchantFirstLegOrders( batchImportFile.value, batchImportMode.value, batchImportIdempotencyKey.value, ) const result = batchImportResult.value.result - if (result.succeeded) await store.load({ includeDrafts: true }) + if (result.succeeded && auth.hasPermission('order:first-leg:list')) await store.load({ includeDrafts: true }) if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`) else ElMessage.success(`已成功导入 ${result.succeeded} 条订单草稿`) } catch (error) { - ElMessage.error(errorMessage(error)) + batchImportError.value = batchImportErrorMessage(error) + ElMessage.error(batchImportError.value) } finally { batchImportSubmitting.value = false } } +function viewImportedDrafts() { + batchImportOpen.value = false + void router.push({ name: 'first-leg-orders' }) +} + async function validateEditorForm() { try { const policy = await getMerchantInboundCodePolicy() @@ -1800,6 +1848,17 @@ async function searchOrders(model: QueryModel) { +
+
+ 需要一次创建多条订单? + 下载正式 Excel 模板,填写后上传;导入成功的数据将创建为订单草稿,不会直接提交审核。 +
+
+ 下载 Excel 模板 + Excel 批量导入 +
+
+ @@ -1874,16 +1933,16 @@ async function searchOrders(model: QueryModel) {

货物明细

- 可逐行维护货物明细;批量订单请返回订单列表使用正式 Excel 导入。 + 可逐行维护货物明细;一次创建多条订单草稿请使用页面上方“Excel 批量导入”。
添加行删除所选行
- + - + @@ -1891,11 +1950,12 @@ async function searchOrders(model: QueryModel) { - + + - - + +
#SKU NO.SKU必填中文品名图片英文品名报关 HS CODE清关 HS CODE包装类型SKU NO.SKU必填中文品名图片英文品名报关 HS CODE清关 HS CODE包装类型材质危险品木制品勾选后须商检 箱规长 cm箱规宽 cm箱规高 cm每箱数量箱数总毛重总体积总数量总净重清关申报单价 USD清关申报金额 USD材质用途型号品牌产品规格 长×宽×高危险品木制品货件编号货件编号必填跟踪编号跟踪编号必填UPC NO.备注操作清关申报单价 USD清关申报金额 USD用途型号品牌产品规格 长×宽×高货件编号货件编号必填跟踪编号跟踪编号必填UPC NO.备注操作
{{ index + 1 }}
第 {{ index + 1 }} 条须商检
××
××
删除
@@ -1941,9 +2001,10 @@ async function searchOrders(model: QueryModel) { - +
- + +
下载正式模板 @@ -1956,7 +2017,10 @@ async function searchOrders(model: QueryModel) {
批次 {{ batchImportResult.batchNo }} - 共 {{ batchImportResult.result.total }} 条,成功 {{ batchImportResult.result.succeeded }} 条,失败 {{ batchImportResult.result.failed }} 条 + 共 {{ batchImportResult.result.total }} 条,已创建草稿 {{ batchImportResult.result.succeeded }} 条,失败 {{ batchImportResult.result.failed }} 条 +
+ 下载 Excel 错误报告 +
  • 第 {{ item.index + 1 }} 行({{ item.clientRowId || '未填写行标识' }}):{{ item.errorMessage || item.errorCode || '导入失败' }} @@ -1964,7 +2028,7 @@ async function searchOrders(model: QueryModel) {
- + @@ -2142,6 +2206,7 @@ async function searchOrders(model: QueryModel) { .batch-import-dialog :deep(.el-radio-group) { display: flex; align-items: flex-start; flex-direction: column; gap: 8px; } .batch-import-result { display: flex; flex-direction: column; gap: 6px; padding: 14px; border: 1px solid var(--border); background: #f8fafc; } .batch-import-result > span, .batch-import-result li { color: var(--muted); font-size: 12px; line-height: 1.55; } +.batch-import-result-actions { display: flex; margin-top: 4px; } .batch-import-result ul { max-height: 180px; margin: 4px 0 0; padding-left: 20px; overflow-y: auto; } .column-settings-trigger { width: 40px; min-width: 40px; height: 36px; padding: 0; color: #53627b; } :global(.order-column-popper.el-popover) { padding: 0; border-color: #e3e8f0; border-radius: 4px; box-shadow: 0 8px 24px rgba(29, 45, 74, .12); } @@ -2196,6 +2261,12 @@ async function searchOrders(model: QueryModel) { .is-create-route { gap: 0; } .is-create-route .first-leg-editor-page { width: 100%; } .is-create-route .supporting-data-error, .is-create-route > .el-alert { margin-bottom: 14px; } +.create-batch-import-entry { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 14px; padding: 14px 18px; border: 1px solid #d9e2ff; border-radius: 8px; background: #f7f9ff; } +.create-batch-import-entry > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.create-batch-import-entry strong { color: var(--navy); font-size: 14px; } +.create-batch-import-entry span { color: var(--muted); font-size: 12px; line-height: 1.5; } +.create-batch-import-actions { display: flex; flex: 0 0 auto; gap: 8px; } +.create-batch-import-actions :deep(.el-button) { min-height: 38px; margin-left: 0; } .is-create-route .prototype-order-form { width: 100%; margin: 0; padding: 0 0 16px; } .is-create-route .prototype-section:not(.cargo-section):not(.cost-estimate-section):not(.destination-section) { width: min(100%, 900px); } .order-form { width: min(100%, 1000px); margin: 0; padding: 0 0 12px; } @@ -2313,7 +2384,9 @@ async function searchOrders(model: QueryModel) { .cargo-number-column { width: 92px !important; } .cargo-dimensions-column { width: 240px !important; } .cargo-operation-column { width: 76px !important; text-align: center !important; } -.cargo-danger-cell { text-align: center; vertical-align: middle !important; } +.cargo-switch-column { width: 112px !important; text-align: center !important; vertical-align: middle !important; } +.cargo-switch-cell { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 4px; } +.cargo-switch-cell small { color: #b54708; font-size: 9px; line-height: 1.25; white-space: normal; } .cargo-table-image { position: relative; display: flex; width: 34px; height: 34px; align-items: center; justify-content: center; margin: 0 auto; border: 1px dashed #d6dce5; border-radius: 4px; color: #788493; } .cargo-table-image img { width: 100%; height: 100%; border-radius: 3px; object-fit: contain; } .cargo-table-image :deep(.el-button) { width: 30px; height: 30px; padding: 0; } @@ -2421,6 +2494,9 @@ async function searchOrders(model: QueryModel) { .product-image-field { grid-row: auto; } } @media (max-width: 720px) { + .create-batch-import-entry { align-items: stretch; flex-direction: column; } + .create-batch-import-actions { flex-direction: column; } + .create-batch-import-actions :deep(.el-button) { width: 100%; } .first-leg-query-toolbar { padding: 12px; } .first-leg-query-toolbar :deep(.query-toolbar__filters) { display: flex; } :global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 10px; } diff --git a/src/navigation/clientNavigation.test.mjs b/src/navigation/clientNavigation.test.mjs index 68c702a..2e17284 100644 --- a/src/navigation/clientNavigation.test.mjs +++ b/src/navigation/clientNavigation.test.mjs @@ -320,7 +320,6 @@ test('keeps prototype views cataloged while allowing implemented order and overs 'order-management', ].map((moduleName) => readFileSync(new URL(`../modules/${moduleName}/routes.ts`, import.meta.url), 'utf8')).join('\n') const routes = [ - ['first-leg/import', 'BatchImportView.vue'], ['first-leg/quick-order', 'QuickOrderView.vue'], ['cross-border/procurement', 'ProcurementView.vue'], ['cross-border/domestic-trucking', 'DomesticTruckingView.vue'], @@ -343,4 +342,5 @@ test('keeps prototype views cataloged while allowing implemented order and overs assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`)) assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), false) } + assert.doesNotMatch(routerSource, /first-leg\/import|BatchImportView|merchant:first-leg:import/) })