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 批量导入
+
+
+
下单配套数据加载失败{{ message }}
@@ -1841,7 +1900,7 @@ async function searchOrders(model: QueryModel) {
买单退税
整柜散货
-
货物包含木制品,提交前必须选择“是”。
+
第 {{ woodenCargoLines.join('、') }} 条货物已勾选木制品,提交前“是否商检”必须选择“是”。
@@ -1874,16 +1933,16 @@ async function searchOrders(model: QueryModel) {