From 07ba9fcb7c4bb96ba1a4d4ce3b74ef302488a8a9 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 9 Oct 2026 11:48:52 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overseas-warehouse/api/returnOrders.ts | 2 +- .../api/warehouseServices.ts | 2 +- .../views/ReturnOrdersView.test.mjs | 59 +++- .../views/ReturnOrdersView.vue | 32 +- .../views/WarehouseServiceView.vue | 280 ++++++++++++------ .../views/relabelAttachments.test.mjs | 23 +- 6 files changed, 285 insertions(+), 113 deletions(-) diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index e6cda49..31b4ea2 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -16,7 +16,7 @@ export interface ReturnReceipt { labels?: Array<{ cartonMark: string; printableHtml: string }> labelRepairRequired?: boolean } -export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } +export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; length?: number; width?: number; height?: number; weight?: number; volumeCbm?: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number } export interface ReturnPackingDetail { sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[] } diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index c030e92..16c3bb7 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -33,7 +33,7 @@ export interface RelabelInventoryCandidate { destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string availablePackages: number | string; version: number positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }> - sourceType?: 'INBOUND' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string + sourceType?: 'INBOUND' | 'RETURN_CARTON' | 'RETURN_PARCEL'; returnCartonId?: string; cartonMark?: string skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>; relabelledPackages?: number } diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index 651ddc1..90e1872 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -5,6 +5,45 @@ import test from 'node:test' const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') +function submitHarness(failure) { + const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType(')) + const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] } + const form = { overseasWarehouseId: 'WH-1', warehouseContactName: 'Contact', warehouseContactPhone: '123', customerReferenceNo: 'REF-NEW', remark: 'edited', needRelabel: true, items: [{ skuCode: 'SKU-NEW', productNameZh: 'Product', expectedQuantity: 25 }] } + const editingReturnNo = { value: 'RT-TEST' }, showCreate = { value: true }, creating = { value: false } + const submit = new Function('form', 'duplicateSkuIds', 'creating', 'editingReturnNo', 'showCreate', 'props', 'ElMessage', 'updateReturnOrder', 'createReturnOrder', 'createIdempotencyKey', 'resetForm', 'router', 'load', `${source}; return submit`)( + form, { value: new Set() }, creating, editingReturnNo, showCreate, { createPage: false, detailPage: false }, + { warning() {}, success(message) { state.successes.push(message) }, error(message) { state.errors.push(message) } }, + async (returnNo, payload) => { state.requests.push({ returnNo, payload }); if (failure) throw failure }, + async () => assert.fail('Editing must use update'), () => 'edit-test', () => {}, + { async push(route) { state.routes.push(route) } }, async () => { state.loads++ }, + ) + return { submit, state, editingReturnNo, showCreate, creating } +} + +test('编辑退货保存提交修改后的字段,并重新读取当前列表', async () => { + const { submit, state, editingReturnNo, showCreate, creating } = submitHarness() + await submit() + assert.equal(state.requests[0].returnNo, 'RT-TEST') + assert.equal(state.requests[0].payload.customerReferenceNo, 'REF-NEW') + assert.equal(state.requests[0].payload.items[0].expectedQuantity, 25) + assert.equal(state.loads, 1) + assert.deepEqual(state.routes, []) + assert.equal(showCreate.value, false) + assert.equal(editingReturnNo.value, '') + assert.equal(creating.value, false) +}) + +test('编辑退货保存失败显示接口错误并保留编辑内容供重试', async () => { + const { submit, state, editingReturnNo, showCreate, creating } = submitHarness(new Error('只有待审核退货预报可以修改')) + await submit() + assert.deepEqual(state.errors, ['只有待审核退货预报可以修改']) + assert.deepEqual(state.successes, []) + assert.equal(state.loads, 0) + assert.equal(showCreate.value, true) + assert.equal(editingReturnNo.value, 'RT-TEST') + assert.equal(creating.value, false) +}) + test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => { assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/) assert.match(api, /apiBlobRequest/) @@ -81,7 +120,7 @@ const labelHtml = mark => `${labelStyle} function printHarness() { const source = view.slice(view.indexOf('async function printReturnLabel('), view.indexOf('\nfunction openDetail(')) - .replace('receipt: ReturnReceipt', 'receipt') + .replace('receipt: ReturnReceipt | ReturnReceipt[]', 'receipt') const calls = { opened: 0, printed: 0, html: '', warnings: [], errors: [], fetched: [] } const popup = { document: { open() {}, write(html) { calls.html = html }, close() {} }, @@ -119,7 +158,7 @@ test('退货多箱打印保留统一模板尺寸和每箱条码,并按箱分 assert.equal(calls.printed, 1) assert.deepEqual(calls.fetched, []) assert.deepEqual(calls.errors, []) - assert.match(view, /打印全部箱唛(\{\{ receipt\.labels\?\.length \|\| receipt\.boxCount \|\| 1 \}\} 箱)/) + assert.match(view, /打印全部箱唛(\{\{ printableBoxCount \}\} 箱)/) }) test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止打印', async () => { @@ -136,6 +175,22 @@ test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止 assert.deepEqual(calls.errors, []) }) +test('两托各两箱合并为一次四页打印,每箱条码保留且模板样式不重复', async () => { + const { print, calls } = printHarness() + const receipts = [1, 2].map(pallet => ({ + cartonMark: `PALLET-${pallet}`, boxCount: 2, + labels: [1, 2].map(box => ({ cartonMark: `SKU-${pallet}#2-${box}`, printableHtml: labelHtml(`SKU-${pallet}#2-${box}`) })), + printableHtml: `batch-${pallet}`, + })) + await print(receipts) + assert.equal(calls.opened, 1) + assert.equal(calls.printed, 1) + assert.equal((calls.html.match(/
/g) || []).length, 4) + assert.equal(calls.html.split(labelStyle).length - 1, 1) + for (const receipt of receipts) for (const label of receipt.labels) assert.ok(calls.html.includes(`data-barcode="${label.cartonMark}"`)) + assert.deepEqual(calls.errors, []) +}) + test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => { assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/) assert.match(view, /if \(editingReturnNo\.value\) await updateReturnOrder\(editingReturnNo\.value, payload\)/) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 0d7535a..87de609 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -24,6 +24,8 @@ const creating = ref(false) const showCreate = ref(false) const editingReturnNo = ref('') const selected = ref() +const printableReceipts = computed(() => selected.value?.receipts.filter(item => item.unitType !== 'PARCEL' && (item.cartonMark || item.labels?.length)) || []) +const printableBoxCount = computed(() => printableReceipts.value.reduce((total, receipt) => total + (receipt.labels?.length || receipt.boxCount || 1), 0)) const photoUrls = reactive>({}) const rows = ref([]) const total = ref(0) @@ -175,18 +177,21 @@ function previewPhoto(fileId: string | number) { const url = photoUrls[String(fileId)] if (url) window.open(url, '_blank', 'noopener') } -async function printReturnLabel(receipt: ReturnReceipt) { - if (receipt.labelRepairRequired) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return } - if (!receipt.cartonMark) return +async function printReturnLabel(receipt: ReturnReceipt | ReturnReceipt[]) { + const receipts = Array.isArray(receipt) ? receipt : [receipt] + if (receipts.some(item => item.labelRepairRequired)) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return } + if (!receipts.length) return const popup = window.open('', '_blank') if (!popup) { ElMessage.warning('请允许浏览器打开打印窗口'); return } popup.opener = null try { - const labels = receipt.labels?.length - ? receipt.labels - : [{ cartonMark: receipt.cartonMark, printableHtml: receipt.printableHtml || (await getReturnInventoryLabel(receipt.cartonMark)).printableHtml }] + const groups = await Promise.all(receipts.map(async item => item.labels?.length + ? await Promise.all(item.labels.map(async label => ({ ...label, printableHtml: label.printableHtml || (await getReturnInventoryLabel(label.cartonMark)).printableHtml }))) + : item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml || (await getReturnInventoryLabel(item.cartonMark)).printableHtml }] : [])) + const labels = [...new Map(groups.flat().map(label => [label.cartonMark, label])).values()] + if (!labels.length) { popup.close(); ElMessage.warning('暂无可打印的箱唛'); return } const printStyles = [...new Set(labels.flatMap(label => label.printableHtml.match(/]*>[\s\S]*?<\/style>/gi) || []))].join('') - const printableHtml = receipt.printableHtml || `退货箱唛${printStyles}${labels.map((label) => { + const printableHtml = (receipts.length === 1 ? receipts[0]?.printableHtml : undefined) || `退货箱唛${printStyles}${labels.map((label) => { const html = label.printableHtml || '' const bodyStart = html.indexOf('', bodyStart) + 1 @@ -221,7 +226,12 @@ async function submit() { const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: createIdempotencyKey(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) } if (editingReturnNo.value) await updateReturnOrder(editingReturnNo.value, payload) else await createReturnOrder(payload) - ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel' }) + const wasEditing = Boolean(editingReturnNo.value) + ElMessage.success(wasEditing ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm() + if (props.createPage || props.detailPage) await router.push({ name: 'overseas-return-relabel' }) + else await load() + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '保存退货预报失败') } finally { creating.value = false } } function statusType(status: string, needRelabel = false) { @@ -368,10 +378,11 @@ onBeforeUnmount(clearPhotoUrls)
-

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ receipt.labels?.length || receipt.boxCount || 1 }} 箱)

+

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ printableBoxCount }} 箱)

- + +
长 {{ pallet.length ?? '—' }} cm · 宽 {{ pallet.width ?? '—' }} cm · 高 {{ pallet.height ?? '—' }} cm · 重量 {{ pallet.weight ?? '—' }} kg
@@ -462,6 +473,7 @@ onBeforeUnmount(clearPhotoUrls) .return-detail-section :deep(.el-table .el-tag) { font-size:12px; } .return-pallet-list { padding:0 14px 10px; } .return-pallet-summary { margin-left:12px; color:#7c899c; font-size:12px; font-weight:400; } +.return-pallet-measurements { margin-bottom:8px; color:#7c899c; font-size:12px; } .return-photo-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; padding:0 16px 16px; } .return-photo { min-width:0; overflow:hidden; padding:0; border:1px solid #dfe6ef; border-radius:6px; color:#526176; background:#fff; cursor:pointer; } .return-photo img,.return-photo>span { display:flex; width:100%; height:120px; align-items:center; justify-content:center; object-fit:cover; background:#f4f7fb; } diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index de8ff08..c7640d5 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -26,16 +26,14 @@ import { } from '@/modules/first-leg/api/firstLeg' import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination' import { createIdempotencyKey } from '@/utils/idempotencyKey' -import { createReturnRelabel, uploadReturnRelabelFile } from '../api/returnOrders' type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'edit' | 'detail' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type RelabelTemplateType = 'carton' | 'sku' | 'mixed' -interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } -interface ReturnRelabelSkuRow { skuCode: string; productNameZh: string; availableQuantity: number; selected: boolean; quantity: number } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string } @@ -189,16 +187,21 @@ const selectedDiscardOrderId = ref('') const discardCandidatesLoading = ref(false) const relabelCandidates = ref([]) const selectedRelabelInboundOrderId = ref('') +const orderInfoCollapsed = ref(false) const relabelCandidatesLoading = ref(false) const transferInventoryCandidates = ref([]) const selectedTransferInventory = ref('') // Snowflake IDs exceed JavaScript's safe integer range. Keep them as strings // all the way to JSON so the backend receives the exact source order ID. const selectedTransferSourceOrderIds = ref([]) -const returnRelabelSkuRows = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') -const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && !isReturnRelabelSource.value && labelingMode.value === 'carton_and_sku') +const hideReturnSkuCartonMark = computed(() => isReturnRelabelSource.value && labelingMode.value === 'sku') +const selectedReturnCandidates = computed(() => relabelCandidates.value.filter(item => Boolean(item.returnCartonId) && item.inboundOrderNo === selectedReturnOrderNo.value)) +const returnBoxCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) +const returnLooseCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) +const returnSkuOnly = computed(() => isReturnRelabelSource.value && returnLooseCandidates.value.length > 0 && returnBoxCandidates.value.length === 0) +const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && labelingMode.value === 'carton_and_sku') function inventoryQuantity(value: number | string | null | undefined) { const quantity = Number(value) return Number.isFinite(quantity) && quantity > 0 ? quantity : 0 @@ -207,7 +210,7 @@ const relabelInboundOptions = computed(() => { const grouped = new Map() for (const candidate of relabelCandidates.value) { const returnOrderNo = candidate.inboundOrderNo || String(candidate.returnCartonId || '') - const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) + const value = candidate.returnCartonId ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) const current = grouped.get(value) if (current) { current.availablePackages += inventoryQuantity(candidate.availablePackages) @@ -215,11 +218,11 @@ const relabelInboundOptions = computed(() => { } else grouped.set(value, { value, - inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), - inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '已签收退货库存' : (candidate.inboundCode || '—'), + inboundOrderNo: candidate.returnCartonId ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), + inboundCode: candidate.returnCartonId ? '已签收退货库存' : (candidate.inboundCode || '—'), availablePackages: inventoryQuantity(candidate.availablePackages), relabelledPackages: inventoryQuantity(candidate.relabelledPackages), - quantityUnit: candidate.sourceType === 'RETURN_CARTON' ? '件' : '箱', + quantityUnit: candidate.returnCartonId ? '件' : '箱', }) } return [...grouped.values()] @@ -263,7 +266,10 @@ watch(() => cargoRows.value.map((row) => row.sku), (skus) => { if (sourceSku) row.sku = sourceSku }) }) -watch(labelingMode, (value) => { if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() }) +watch([returnSkuOnly, labelingMode], ([skuOnly, value]) => { + if (skuOnly && value !== 'sku') { labelingMode.value = 'sku'; return } + if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() +}) watch(() => form.destination, (value) => { if (props.service !== 'labeling') return for (const row of cargoRows.value) row.destinationWarehouseId = value @@ -293,6 +299,7 @@ onMounted(() => { onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() }) function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetForm() { + orderInfoCollapsed.value = false Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) cargoRows.value = [] transferRelabelRows.value = [] @@ -490,7 +497,7 @@ function clearTransferRelabelRows() { function addSkuRelabelRow() { const source = cargoRows.value[Math.min(skuRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))] skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: source?.id || null, - cartonMark: '', originalSku: source?.sku || '', newSku: '', quantity: 1, + cartonMark: hideReturnSkuCartonMark.value ? source?.cartonMark || '' : '', originalSku: source?.sku || '', newSku: '', quantity: 1, fbaCartonMark: '', destinationWarehouseId: form.destination, remark: '' }) } function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) } @@ -532,6 +539,16 @@ function chooseCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') { function buildSkuRelabelItems(): RelabelCreate['items'] { const groups = new Map() for (const row of skuRelabelRows.value) { + if (hideReturnSkuCartonMark.value) { + const source = cargoRows.value.find(item => item.id === row.sourceRowId) + const candidate = selectedReturnCandidates.value.find(item => String(item.returnCartonId) === source?.returnCartonId) + if (candidate?.skuItems?.some(item => item.skuCode === row.originalSku.trim())) row.cartonMark = source?.cartonMark || '' + else { + const matches = selectedReturnCandidates.value.filter(item => item.skuItems?.some(sku => sku.skuCode === row.originalSku.trim())) + if (matches.length !== 1) throw new Error(`请从上方库存明细选择 SKU ${row.originalSku} 的来源库存`) + row.cartonMark = matches[0].cartonMark || '' + } + } const cartonMark = row.cartonMark.trim().toUpperCase() if (!cartonMark) throw new Error('请填写每个箱子的我方箱唛') const rows = groups.get(cartonMark) || [] @@ -546,7 +563,20 @@ function buildSkuRelabelItems(): RelabelCreate['items'] { throw new Error('填写的箱唛数量超过当前入仓单可用库存箱数') } return [...groups.entries()].map(([_, rows], groupIndex) => { - const source = cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex] + const source = isReturnRelabelSource.value + ? cargoRows.value.find(item => item.cartonMark === rows[0].cartonMark.trim()) + : cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex] + if (isReturnRelabelSource.value && !source) throw new Error('请选择该退货单实际可用的来源箱号') + if (isReturnRelabelSource.value) { + const candidate = selectedReturnCandidates.value.find(item => String(item.returnCartonId) === source?.returnCartonId) + if (candidate?.sourceType === 'RETURN_PARCEL' && labelingMode.value !== 'sku') throw new Error('散件库存只能贴 SKU,请先合箱后申请箱唛或混贴') + const requested = new Map() + for (const row of rows) { + const quantity = (requested.get(row.originalSku) || 0) + row.quantity + requested.set(row.originalSku, quantity) + if (quantity > Number(candidate?.skuItems?.find(item => item.skuCode === row.originalSku)?.quantity || 0)) throw new Error(`SKU ${row.originalSku} 超过来源箱可用数量`) + } + } if (!source?.returnCartonId && (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId)) { throw new Error('来源库存已变化,请重新选择有库存的历史入仓单') } @@ -949,48 +979,18 @@ function listRowActions(candidate: object): TableAction[] { if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) }) return actions } -async function submitReturnRelabel() { - const items = returnRelabelSkuRows.value.filter(item => item.selected) - .map(item => ({ skuCode: item.skuCode, quantity: Number(item.quantity) })) - if (!selectedReturnOrderNo.value || !items.length) { ElMessage.warning('请至少选择一个需要贴标的 SKU'); return } - if (items.some(item => item.quantity < 1 || item.quantity > (returnRelabelSkuRows.value.find(row => row.skuCode === item.skuCode)?.availableQuantity || 0))) { - ElMessage.warning('本次贴标数量必须大于 0,且不能超过该 SKU 的库存总数'); return - } - const destination = fbaWarehouses.value.find(warehouse => warehouse.warehouseCode === form.destination) - if (!destination) { ElMessage.warning('请选择 FBA 目的仓'); return } - const selectedFile = cartonTotalFile.value || skuTotalFile.value - const fileScope = cartonTotalFile.value ? 'CARTON_TOTAL' as const : 'SKU_TOTAL' as const - if (!selectedFile) { ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return } - try { await ElMessageBox.confirm(`确认提交退货单 ${selectedReturnOrderNo.value} 的贴标申请吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return } - saving.value = true - try { - const created = await createReturnRelabel(selectedReturnOrderNo.value, { - items, - fileScope, - destinationFbaWarehouseId: String(destination.id), - destinationFbaWarehouseCode: destination.warehouseCode, - cartonRemark: form.remark.trim() || undefined, - idempotencyKey: createIdempotencyKey(), - }) - await uploadReturnRelabelFile(created.relabelNo, fileScope, selectedFile) - ElMessage.success(`贴标申请 ${created.relabelNo} 已提交,等待审核`) - await router.push({ name: 'overseas-labeling' }) - } catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货贴标申请提交失败,请重试') } - finally { saving.value = false } -} watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => { if (nextSize !== previousSize && nextPage !== 1) { page.value = 1; return } void load() }) async function submit(action: 'draft' | 'submit' = 'submit') { - if (isReturnRelabelSource.value) { await submitReturnRelabel(); return } const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value const invalidCargo = props.service === 'disposal' ? !selectedDiscardOrderId.value || cargoRows.value.every((item) => item.quantity < 1) || cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0)) : (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton' - ? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 + ? skuRelabelRows.value.some((item) => (!hideReturnSkuCartonMark.value && !item.cartonMark) || !item.originalSku || !item.newSku || item.quantity < 1 || (labelingMode.value === 'carton_and_sku' && !item.fbaCartonMark)) : cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || (props.service !== 'labeling' && item.quantity < 1)))) @@ -1019,7 +1019,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) { ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return } - if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.sourceInventoryPositionId)) { + if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.returnCartonId && !item.sourceInventoryPositionId)) { ElMessage.warning('请选择有库存的历史入仓单'); return } if (props.service === 'disposal' && !form.reason) { @@ -1399,34 +1399,17 @@ function selectRelabelInventory(inboundOrderId: string) { const rows: CargoRow[] = [] const selectedCandidates = relabelCandidates.value.filter((item) => { const returnOrderNo = item.inboundOrderNo || String(item.returnCartonId || '') - return (item.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId + return (item.returnCartonId ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId }) - returnRelabelSkuRows.value = [] if (inboundOrderId.startsWith('RETURN_ORDER:')) { - const grouped = new Map() - for (const candidate of selectedCandidates) { - const skuItems = candidate.skuItems?.length - ? candidate.skuItems - : candidate.skuCode ? [{ skuCode: candidate.skuCode, quantity: inventoryQuantity(candidate.availablePackages) }] : [] - for (const item of skuItems) { - const skuCode = item.skuCode.trim() - if (!skuCode) continue - const key = skuCode.toUpperCase() - const current = grouped.get(key) - const quantity = inventoryQuantity(item.quantity) - if (current) current.availableQuantity += quantity - else grouped.set(key, { - skuCode, - productNameZh: candidate.skuCode?.trim().toUpperCase() === key ? (candidate.productNameZh || '') : '', - availableQuantity: quantity, - selected: false, - // 可贴库存和本次申请数量是两个概念;重复换标时默认不应自动带入上次/首个箱子的数量。 - quantity: 0, - }) - } - } - returnRelabelSkuRows.value = [...grouped.values()] labelingMode.value = 'sku' + for (const candidate of selectedCandidates) rows.push({ + id: ++cargoSequence, sku: candidate.skuItems?.[0]?.skuCode || candidate.skuCode || '', + name: candidate.productNameZh || '退货库存', included: false, quantity: 1, availableQuantity: 1, + returnCartonId: String(candidate.returnCartonId), sourceInboundOrderNo: candidate.inboundOrderNo, + fnsku: '', shipmentNo: candidate.inboundOrderNo || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', + cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', + }) } else for (const candidate of selectedCandidates) { for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(inventoryQuantity(position.availablePackages), 500) }, () => ({ id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物', @@ -1440,7 +1423,7 @@ function selectRelabelInventory(inboundOrderId: string) { }))) } cargoRows.value = rows.slice(0, 500) - if (cargoRows.value.length) cargoRows.value[0].included = true + if (cargoRows.value.length && !inboundOrderId.startsWith('RETURN_ORDER:')) cargoRows.value[0].included = true skuRelabelRows.value = [] importedRelabelBindings.value = [] shippingLabelFile.value = null @@ -1448,6 +1431,22 @@ function selectRelabelInventory(inboundOrderId: string) { skuTotalFile.value = null if (!isReturnRelabelSource.value && labelingMode.value !== 'carton') addSkuRelabelRow() } +function addReturnSkuBinding(source: RelabelInventoryCandidate, skuCode: string) { + const sku = source.skuItems?.find(item => item.skuCode === skuCode) + if (!sku) return + const row = skuRelabelRows.value.find(item => item.cartonMark === source.cartonMark && item.originalSku === skuCode) + if (row) return + const sourceRow = cargoRows.value.find(item => item.returnCartonId === String(source.returnCartonId)) + skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: sourceRow?.id || null, cartonMark: source.cartonMark || '', originalSku: skuCode, newSku: '', quantity: Math.max(1, Number(sku.quantity)), fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) +} +function addReturnBoxBinding(source: RelabelInventoryCandidate) { + if (labelingMode.value !== 'carton') { + for (const sku of source.skuItems || []) addReturnSkuBinding(source, sku.skuCode) + return + } + if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return + cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '退货箱', shipmentNo: selectedReturnOrderNo.value, cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) +} @@ -501,6 +505,14 @@ onBeforeUnmount(clearPhotoUrls) .return-photo { min-width:0; overflow:hidden; padding:0; border:1px solid #dfe6ef; border-radius:6px; color:#526176; background:#fff; cursor:pointer; } .return-photo img,.return-photo>span { display:flex; width:100%; height:120px; align-items:center; justify-content:center; object-fit:cover; background:#f4f7fb; } .return-photo small { display:block; overflow:hidden; padding:7px; text-overflow:ellipsis; white-space:nowrap; } +.return-parcel-items { margin:10px 16px 16px; overflow:hidden; border:1px solid #dfe6ef; border-radius:8px; background:#fbfcfe; } +.return-parcel-items__head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 14px; color:#26364f; background:#f3f7fc; border-bottom:1px solid #e7edf5; } +.return-parcel-items__head span { color:#1764c0; font-size:12px; font-weight:600; } +.return-parcel-items__list { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:8px; padding:10px; } +.return-parcel-item { display:grid; grid-template-columns:minmax(110px,1fr) minmax(100px,1.4fr) auto; align-items:center; gap:10px; min-height:38px; padding:7px 10px; border:1px solid #edf1f5; border-radius:6px; background:#fff; } +.return-parcel-item__sku { color:#2c3c55; font-weight:600; } +.return-parcel-item__name { overflow:hidden; color:#8490a1; font-size:12px; text-overflow:ellipsis; white-space:nowrap; } +.return-parcel-item strong { color:#1764c0; white-space:nowrap; } @media (max-width: 760px) { .return-create-form { grid-template-columns:1fr; } } @media (max-width: 1100px) { .return-detail-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width: 900px) { .return-photo-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } } From d7f8365ecc5d172a9cd9377b7d8db31181e69b25 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 02:19:39 +0800 Subject: [PATCH 7/9] feat(procurement): show customer sample fulfillment and charges --- AGENTS.md | 3 +- src/components.d.ts | 1 + .../cross-border/api/businessOrders.ts | 57 +----- src/modules/cross-border/api/procurement.ts | 108 ++++++++++ .../cross-border/businessOrders.test.mjs | 17 +- .../components/BusinessOrdersView.vue | 144 +++----------- .../components/ProcurementDetailPanel.vue | 106 ++++++++++ src/modules/cross-border/procurement.test.mjs | 165 ++++++++++++++++ .../cross-border/procurementPresentation.ts | 123 ++++++++++++ .../cross-border/procurementSubmission.ts | 24 +++ .../cross-border/views/ProcurementView.vue | 187 +++++++++++++++++- .../overseas-warehouse/permissions.test.mjs | 5 +- .../views/warehouseTransferForm.test.mjs | 6 +- 13 files changed, 759 insertions(+), 187 deletions(-) create mode 100644 src/modules/cross-border/api/procurement.ts create mode 100644 src/modules/cross-border/components/ProcurementDetailPanel.vue create mode 100644 src/modules/cross-border/procurement.test.mjs create mode 100644 src/modules/cross-border/procurementPresentation.ts create mode 100644 src/modules/cross-border/procurementSubmission.ts diff --git a/AGENTS.md b/AGENTS.md index 1cbfbe0..2e51930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | | 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的平台统一静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | -| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | +| 海外采购 | `src/modules/cross-border/routes.ts`、`views/ProcurementView.vue`、`components/ProcurementDetailPanel.vue`、`api/procurement.ts` | 独立列表、新建及详情路由;客户范围只取 Token,创建的 `customerName` 只取认证会话企业名称。详情消费独立客户投影,展示履约、已确认箱明细、实际运单和费用结果,不开放内部文件或平台操作。Backend `lastleg-service/procurement/` | +| 美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 两个运输业务共用列表/表单组件,客户范围只取 Token;不消费采购字段或状态。Backend `lastleg-service/` 的 `trucking/`、`looseboard/` 客户接口 | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | | 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | diff --git a/src/components.d.ts b/src/components.d.ts index bcd7883..acad601 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -42,6 +42,7 @@ declare module 'vue' { ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElResult: typeof import('element-plus/es')['ElResult'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index 5bb7ee3..ae51b30 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -8,7 +8,7 @@ export interface PageResult { pages: number } -export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard' +export type CrossBorderBusinessKind = 'trucking' | 'looseboard' export interface BusinessOrderQuery { page: number @@ -16,8 +16,6 @@ export interface BusinessOrderQuery { orderNo?: string status?: string routeKeyword?: string - productName?: string - purchaseCategory?: string transportType?: string transportMode?: string requestedFrom?: string @@ -26,54 +24,6 @@ export interface BusinessOrderQuery { createdTo?: string } -export interface ProcurementSummary { - orderNo: string - purchaseCategory: string - productName: string - productSpecification: string | null - purchaseQuantity: number - budgetUnitPrice: number | null - currency: string | null - inboundQuantity: number - outboundQuantity: number - inventoryQuantity: number - status: string - version: number - createdAt: string - updatedAt: string -} - -export interface ProcurementDetail { - summary: ProcurementSummary - productUrl: string | null - receivingAddress: string | null - contactName: string - contactPhone: string - domesticCarrier: string | null - domesticTrackingNo: string | null - overseasCarrier: string | null - overseasTrackingNo: string | null - estimatedArrivalAt: string | null - remark: string | null - reviewRemark: string | null -} - -export interface ProcurementCreate { - customerName: string - purchaseCategory: string - productName: string - productSpecification?: string - productUrl?: string - purchaseQuantity: number - budgetUnitPrice?: number - currency?: string - receivingAddress?: string - contactName: string - contactPhone: string - remark?: string - idempotencyKey: string -} - export interface TransportSummary { fleetArrangement: import('../fleetArrangement').FleetArrangement | null orderNo: string @@ -132,7 +82,6 @@ export interface TransportUpdate extends Omit } const endpointByKind: Record = { - procurement: '/merchant/v1/last-leg/overseas-procurements', trucking: '/merchant/v1/last-leg/us-truck-orders', looseboard: '/merchant/v1/last-leg/local-looseboard-orders', } @@ -149,10 +98,6 @@ export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: stri return apiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) } -export function createProcurement(payload: ProcurementCreate) { - return apiRequest({ method: 'POST', url: endpointByKind.procurement, data: payload }) -} - export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { return apiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) } diff --git a/src/modules/cross-border/api/procurement.ts b/src/modules/cross-border/api/procurement.ts new file mode 100644 index 0000000..6b1b6ca --- /dev/null +++ b/src/modules/cross-border/api/procurement.ts @@ -0,0 +1,108 @@ +import { apiRequest } from '@/api/http' +import type { PageResult } from './businessOrders' + +export type Amount = number | string +export interface ProcurementSummary { + orderNo: string + purchaseCategory: string + productName: string + productSpecification: string | null + purchaseQuantity: number + budgetUnitPrice: Amount | null + currency: string | null + status: string + version: number + createdAt: string + updatedAt: string +} +export interface ProcurementOrder extends ProcurementSummary { + productUrl: string | null + receivingAddress: string + contactName: string + contactPhone: string + estimatedArrivalAt: string | null + plannedWarehouseName: string | null + actualWarehouseCode: string | null + actualWarehouseName: string | null +} +export interface ProcurementFee { + feeNo: string + purpose: string + name: string + version: number | null + active: boolean + determination: string + amount: Amount + currency: string + originalAmount: Amount | null + originalCurrency: string | null + exchangeRate: Amount | null + exchangeRateMonth: string | null + businessDate: string | null + parentFeeNo: string | null + walletStatus: string | null + sourceLinked: boolean +} +export interface ProcurementBox { + cartonNo: string + quantity: number + lengthCm: Amount + widthCm: Amount + heightCm: Amount + grossWeightKg: Amount +} +export interface ProcurementShipment { + shipmentNo: string + leg: 'OVERSEAS' | 'DOMESTIC' + shippedAt: string + cartonNo: string + quantity: number + carrier: string + trackingNo: string +} +export interface ProcurementDetail { + order: ProcurementOrder + budget: { totalAmount: Amount | null; currency: string; authorizedAt: string | null } + purchased: boolean + purchaseConfirmed: boolean + purchaseNeedsAttention: boolean + progress: { key: string; title: string; completedAt: string | null }[] + boxes: ProcurementBox[] + shipments: ProcurementShipment[] + fees: ProcurementFee[] + exceptionSummaries: string[] +} +export interface ProcurementCreate { + customerName: string + purchaseCategory: string + productName: string + productSpecification?: string + productUrl?: string + purchaseQuantity: number + budgetUnitPrice: number + currency: 'CNY' | 'USD' + receivingAddress: string + contactName: string + contactPhone: string + remark?: string + idempotencyKey: string +} +export interface ProcurementQuery { + page: number + size: number + orderNo?: string + productName?: string + purchaseCategory?: string + status?: string +} +const endpoint = '/merchant/v1/last-leg/overseas-procurements' +export async function listProcurements(params: ProcurementQuery) { + const result = await apiRequest>({ method: 'GET', url: endpoint, params }) + return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) } +} +export function getProcurement(orderNo: string) { + return apiRequest({ method: 'GET', url: `${endpoint}/${encodeURIComponent(orderNo)}` }) +} +export function createProcurement(data: ProcurementCreate) { + return apiRequest({ method: 'POST', url: endpoint, data }) +} diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 01d11f0..38830ef 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -7,6 +7,8 @@ import ts from 'typescript' const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') +const procurementApi = readFileSync(new URL('./api/procurement.ts', import.meta.url), 'utf8') +const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') test('renders unknown pallet service and container type as dashes while preserving explicit values', () => { const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey(')) @@ -42,14 +44,15 @@ test('activates all three customer cross-border pages with frozen permissions', assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`)) } assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i) - assert.match(view, /merchant:cross-border:procurement:create/) + assert.match(procurementView, /merchant:cross-border:procurement:create/) assert.match(view, /merchant:cross-border:domestic-trucking:create/) assert.match(view, /merchant:cross-border:pallet-shipping:create/) assert.match(view, /v-if="canCreate"[^>]*type="primary"/) }) test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => { - assert.match(api, /overseas-procurements/) + assert.match(procurementApi, /overseas-procurements/) + assert.doesNotMatch(api, /overseas-procurements|ProcurementSummary|ProcurementDetail/) assert.match(api, /us-truck-orders/) assert.match(api, /local-looseboard-orders/) assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/) @@ -69,11 +72,9 @@ test('covers list detail create loading error empty pagination and actions-first assert.match(view, /]*:row-actions="getRowActions"/) assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/) assert.match(view, /class="business-table-card"/) - assert.match(view, /PENDING_OUTBOUND: '待出库'/) - assert.match(view, /PARTIALLY_OUTBOUND: '部分出库'/) - assert.match(view, /OUTBOUNDED: '全部出库'/) - assert.match(view, /\['入库量', read\(summary, 'inboundQuantity'\)\]/) - assert.match(view, /\['审核备注', current\.reviewRemark\]/) + assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity|reviewRemark/) + assert.match(procurementView, /procurementStatuses/) + assert.match(procurementView, /ProcurementDetailPanel/) }) test('opens every dashboard cross-border action on a dedicated protected create page', () => { @@ -100,7 +101,7 @@ test('opens all cross-border details on dedicated protected routes', () => { assert.match(view, /class="business-detail-error"/) }) -test('keeps procurement filters, details and forms readable at PDA width', () => { +test('preserves the existing transport responsive rules without adding a narrow-screen procurement acceptance gate', () => { assert.match(view, /@media \(max-width: 767px\)/) assert.match(view, /queryFields/) assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index 6ddbf6e..4793615 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -7,13 +7,10 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/components/com import type { TableAction } from '@/components/common/tableActions' import { useAuthStore } from '@/modules/auth/public' import { - createProcurement, createTransport, getBusinessOrder, listBusinessOrders, type CrossBorderBusinessKind, - type ProcurementDetail, - type ProcurementSummary, type TransportDetail, type TransportSummary, } from '../api/businessOrders' @@ -26,23 +23,17 @@ const router = useRouter() const route = useRoute() const detailOrderNo = computed(() => String(route.params.orderNo || '')) const canCreate = computed(() => auth.hasPermission(({ - procurement: 'merchant:cross-border:procurement:create', trucking: 'merchant:cross-border:domestic-trucking:create', looseboard: 'merchant:cross-border:pallet-shipping:create', } as Record)[props.kind])) -type BusinessRow = ProcurementSummary | TransportSummary -type BusinessDetail = ProcurementDetail | TransportDetail +type BusinessRow = TransportSummary +type BusinessDetail = TransportDetail type PageMode = 'list' | 'create' | 'detail' interface ColumnDefinition { key: string; label: string; width?: number } interface BusinessForm { - purchaseCategory: string - productName: string - productSpecification: string - productUrl: string - purchaseQuantity: number transportMode: string palletRequired: string transportType: string @@ -60,28 +51,10 @@ interface BusinessForm { contactName: string contactInfo: string requestedDate: string - receivingAddress: string remark: string } const configuration = computed(() => ({ - procurement: { - title: '海外采购', - description: '查询并管理海外采购订单', - columns: [ - { key: 'orderNo', label: '订单号', width: 180 }, - { key: 'purchaseCategory', label: '采购品类', width: 130 }, - { key: 'productName', label: '商品名称', width: 160 }, - { key: 'productSpecification', label: '商品规格', width: 160 }, - { key: 'purchaseQuantity', label: '采购数量', width: 100 }, - { key: 'budgetUnitPrice', label: '预算单价', width: 110 }, - { key: 'inboundQuantity', label: '入库量', width: 90 }, - { key: 'outboundQuantity', label: '出库量', width: 90 }, - { key: 'inventoryQuantity', label: '库存量', width: 90 }, - { key: 'status', label: '状态', width: 120 }, - { key: 'createdAt', label: '创建时间', width: 170 }, - ] satisfies ColumnDefinition[], - }, trucking: { title: '美国本土卡', description: '查询并管理美国本土卡订单', @@ -117,23 +90,19 @@ const submitKey = ref('') const error = ref('') const rows = ref([]) const detail = ref(null) -const fleetArrangement = computed(() => detail.value && props.kind !== 'procurement' ? (detail.value as TransportDetail).summary.fleetArrangement : null) +const fleetArrangement = computed(() => detail.value ? (detail.value as TransportDetail).summary.fleetArrangement : null) const formRef = ref() -const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) +const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }) const total = ref(0) const queryModel = computed({ get: () => ({ orderNo: query.orderNo, - purchaseCategory: query.purchaseCategory, - productName: query.productName, routeKeyword: query.routeKeyword, status: query.status, }), set: (value) => { query.orderNo = String(value.orderNo || '') - query.purchaseCategory = String(value.purchaseCategory || '') - query.productName = String(value.productName || '') query.routeKeyword = String(value.routeKeyword || '') query.status = String(value.status || '') }, @@ -141,12 +110,7 @@ const queryModel = computed({ const queryFields = computed(() => [ { key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' }, - ...(props.kind === 'procurement' - ? [ - { key: 'purchaseCategory', label: '采购品类', type: 'input', placeholder: '请输入采购品类' } as QueryField, - { key: 'productName', label: '商品名称', type: 'input', placeholder: '请输入商品名称' } as QueryField, - ] - : [{ key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' } as QueryField]), + { key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' }, { key: 'status', label: '状态', @@ -156,11 +120,8 @@ const queryFields = computed(() => [ { label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '已拒绝', value: 'REJECTED' }, - { label: '待出库', value: 'PENDING_OUTBOUND' }, - { label: '部分出库', value: 'PARTIALLY_OUTBOUND' }, - { label: '全部出库', value: 'OUTBOUNDED' }, { label: '待接单', value: 'PENDING_ACCEPTANCE' }, - ...(props.kind === 'procurement' ? [] : Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label }))), + ...Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label })), { label: '处理中', value: 'PROCESSING' }, { label: '运输中', value: 'IN_TRANSIT' }, { label: '已完成', value: 'COMPLETED' }, @@ -171,9 +132,6 @@ const queryFields = computed(() => [ const form = reactive(emptyForm()) const rules: FormRules = { - purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }], - productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }], - purchaseQuantity: [{ required: true, type: 'number', min: 1, message: '采购数量至少为 1', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }], palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }], @@ -185,21 +143,6 @@ const rules: FormRules = { const detailFields = computed(() => { if (!detail.value) return [] const summary = detail.value.summary - if (props.kind === 'procurement') { - const current = detail.value as ProcurementDetail - return [ - ['采购品类', read(summary, 'purchaseCategory')], - ['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')], - ['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))], - ['入库量', read(summary, 'inboundQuantity')], ['出库量', read(summary, 'outboundQuantity')], - ['库存量', read(summary, 'inventoryQuantity')], ['预计到达时间', dateTime(current.estimatedArrivalAt)], - ['联系人', current.contactName], ['联系电话', current.contactPhone], - ['收货地址', current.receivingAddress], ['商品链接', current.productUrl], - ['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)], - ['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)], - ['审核备注', current.reviewRemark], ['备注', current.remark], - ] - } const current = detail.value as TransportDetail return [ ['运输方式', transportModeLabel(read(summary, 'transportMode'))], @@ -228,18 +171,16 @@ const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summ function emptyForm(): BusinessForm { return { - purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1, transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '', destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '', - receivingAddress: '', remark: '', + remark: '', } } function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() } function read(source: object, key: string) { return Reflect.get(source, key) as unknown } function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) } -function joinText(...values: unknown[]) { const result = values.filter(Boolean).join(' / '); return result || '—' } function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` } function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' } function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' ? '否' : text(value) } @@ -247,10 +188,10 @@ function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提 function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record)[String(value)] || text(value) } function statusLabel(value: unknown) { if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)] - return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record)[String(value)] || text(value) + return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record)[String(value)] || text(value) } function statusTone(value: unknown) { - return ({ APPROVED: 'success', COMPLETED: 'success', OUTBOUNDED: 'success', REJECTED: 'danger', CANCELLED: 'info', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record)[String(value)] || 'info' + return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record)[String(value)] || 'info' } function displayCell(candidate: unknown, key: string) { const row = candidate as BusinessRow @@ -260,7 +201,7 @@ function displayCell(candidate: unknown, key: string) { if (key === 'transportMode') return transportModeLabel(value) if (key === 'palletRequired') return yesNo(value) if (key === 'createdAt') return dateTime(value) - if (key === 'budgetUnitPrice' || key === 'budgetAmount') return money(value, read(row, 'currency')) + if (key === 'budgetAmount') return money(value, read(row, 'currency')) return text(value) } function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` } @@ -272,12 +213,9 @@ async function load() { try { const params = { page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined, - routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined, - purchaseCategory: query.purchaseCategory || undefined, + routeKeyword: query.routeKeyword || undefined, } - const result = props.kind === 'procurement' - ? await listBusinessOrders('procurement', params) - : await listBusinessOrders(props.kind, params) + const result = await listBusinessOrders(props.kind, params) rows.value = result.records total.value = result.total } catch (cause) { @@ -289,7 +227,7 @@ async function load() { function resetQuery() { const reloadDirectly = query.page === 1 - Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) + Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '' }) if (reloadDirectly) void load() } @@ -303,16 +241,14 @@ function getRowActions(candidate: object): TableAction[] { } function detailFieldClass(label: unknown) { - return ['收货地址', '商品链接', '国内运单', '海外运单', '审核备注', '备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : '' + return ['备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : '' } const listRouteName = computed(() => ({ - procurement: 'cross-border-procurement', trucking: 'cross-border-domestic-trucking', looseboard: 'cross-border-pallet-shipping', } as Record)[props.kind]) const detailRouteName = computed(() => ({ - procurement: 'cross-border-procurement-detail', trucking: 'cross-border-domestic-trucking-detail', looseboard: 'cross-border-pallet-shipping-detail', } as Record)[props.kind]) @@ -334,9 +270,7 @@ async function loadDetail() { loading.value = true error.value = '' try { - detail.value = props.kind === 'procurement' - ? await getBusinessOrder('procurement', detailOrderNo.value) - : await getBusinessOrder(props.kind, detailOrderNo.value) + detail.value = await getBusinessOrder(props.kind, detailOrderNo.value) mode.value = 'detail' } catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) } finally { loading.value = false } @@ -348,31 +282,19 @@ async function submit() { const stableSubmitKey = submitKey.value || idempotencyKey() submitKey.value = stableSubmitKey try { - if (props.kind === 'procurement') { - await createProcurement({ - customerName: auth.user?.tenantName || '', - purchaseCategory: form.purchaseCategory, productName: form.productName, - productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined, - purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount, - currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined, - contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined, - idempotencyKey: stableSubmitKey, - }) - } else { - const base = { - transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined, - destination: form.destination, destinationAddress: form.destinationAddress || undefined, - containerType: form.containerType || undefined, packageCount: form.packageCount, - totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm, - budgetAmount: form.budgetAmount, currency: form.currency || undefined, - contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined, - requestedDate: form.requestedDate || undefined, remark: form.remark || undefined, - idempotencyKey: stableSubmitKey, - } - await createTransport(props.kind, props.kind === 'trucking' - ? { ...base, palletCount: form.palletCount } - : { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired }) + const base = { + transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined, + destination: form.destination, destinationAddress: form.destinationAddress || undefined, + containerType: form.containerType || undefined, packageCount: form.packageCount, + totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm, + budgetAmount: form.budgetAmount, currency: form.currency || undefined, + contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined, + requestedDate: form.requestedDate || undefined, remark: form.remark || undefined, + idempotencyKey: stableSubmitKey, } + await createTransport(props.kind, props.kind === 'trucking' + ? { ...base, palletCount: form.palletCount } + : { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired }) ElMessage.success('订单提交成功') resetForm() if (props.createPage) await router.push({ name: listRouteName.value }) @@ -442,15 +364,6 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat - - - + diff --git a/src/modules/cross-border/components/ProcurementDetailPanel.vue b/src/modules/cross-border/components/ProcurementDetailPanel.vue new file mode 100644 index 0000000..4324b63 --- /dev/null +++ b/src/modules/cross-border/components/ProcurementDetailPanel.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs new file mode 100644 index 0000000..8e8e0eb --- /dev/null +++ b/src/modules/cross-border/procurement.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import * as vue from 'vue' +import { parse, compileScript } from 'vue/compiler-sfc' + +let nextKey = 0 +const crypto = { randomUUID: () => `test-key-${++nextKey}` } +function moduleFrom(source, require = () => { throw new Error('unexpected import') }) { + const context = { exports: {}, require, URL, Error, crypto } + const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText + runInNewContext(code, context) + return context.exports +} +const presentation = moduleFrom(readFileSync(new URL('./procurementPresentation.ts', import.meta.url), 'utf8')) +const submission = moduleFrom(readFileSync(new URL('./procurementSubmission.ts', import.meta.url), 'utf8'), name => { + if (name === './procurementPresentation') return presentation + throw new Error(`unexpected import ${name}`) +}) +const normalized = value => JSON.parse(JSON.stringify(value)) +const draft = () => ({ ...presentation.emptyProcurementDraft(), purchaseCategory: 'Sample', productName: 'Test product', productSpecification: 'A', + purchaseQuantity: '3', budgetUnitPrice: '0.29', receivingAddress: '测试省测试市测试街 1 号', contactName: '测试收件人', contactPhone: '13800000000' }) +const fee = (purpose, walletStatus, amount) => ({ purpose, walletStatus, amount, currency: 'CNY' }) + +test('budget distinguishes unknown from zero, preserves cents, and requires a real telephone/address', () => { + assert.equal(presentation.draftBudgetTotal(draft()), '0.87') + const valid = presentation.buildProcurementCreate(draft(), 'Authenticated Company', 'same-key') + assert.equal(valid.customerName, 'Authenticated Company') + assert.equal(valid.budgetUnitPrice, 0.29) + for (const value of ['', '-1', '1.001', '90071992547409.91']) assert.throws(() => presentation.buildProcurementCreate({ ...draft(), budgetUnitPrice: value }, 'Company', 'key'), /预算/) + assert.equal(presentation.buildProcurementCreate({ ...draft(), budgetUnitPrice: '0' }, 'Company', 'key').budgetUnitPrice, 0) + for (const patch of [{ contactPhone: 'wechat-name' }, { contactPhone: 'a@test.invalid' }, { receivingAddress: '' }, { purchaseQuantity: '1.5' }]) { + assert.throws(() => presentation.buildProcurementCreate({ ...draft(), ...patch }, 'Company', 'key')) + } + assert.equal(presentation.measurement(20, 2), '20.00') + assert.equal(presentation.measurement('5.125', 3), '5.125') + assert.equal(presentation.measurement('1.234', 2), '待核对') + assert.equal(presentation.money(null), '—') + assert.equal(presentation.money(0), 'CNY 0.00') + assert.equal(presentation.productHref('javascript:alert(1)'), undefined) + assert.equal(presentation.productHref('https://example.com/item'), 'https://example.com/item') +}) + +test('successful net debit excludes the original estimate and counts only successful refunds and differences', () => { + const result = presentation.feeTotals([fee('INTERNATIONAL_ESTIMATE', 'SETTLED', '100'), fee('INTERNATIONAL_FREIGHT', 'SETTLED', '100'), + fee('ADJUSTMENT', 'SETTLED', '20'), fee('ADJUSTMENT', 'SETTLED', '10'), fee('REFUND', 'REFUNDED', '20'), fee('REFUND', 'REFUND_PENDING', '5')]) + assert.deepEqual(normalized(result), { paid: '130.00', refunded: '20.00', net: '110.00', estimatedHold: '0.00' }) + assert.equal(presentation.feeTotals([fee('INTERNATIONAL_ESTIMATE', 'RELEASE_PENDING', '100')]).estimatedHold, '100.00') + assert.equal(presentation.feeTotals([fee('DUTY', 'SETTLED', 'unknown')]), null) + assert.equal(presentation.feeStatus(fee('INTERNATIONAL_ESTIMATE', 'FROZEN', 100)), '已冻结,未扣款') + assert.equal(presentation.feeStatus(fee('INTERNATIONAL_ESTIMATE', 'SETTLED', 100)), '原预估已结清') + assert.equal(presentation.feeStatus(fee('DUTY', null, 100)), '待核定') +}) + +test('request recovery preserves content and identity while malformed recovery cannot create a replacement', () => { + const original = presentation.buildProcurementCreate(draft(), 'Company', 'fixed-key') + assert.deepEqual(normalized(submission.restoreProcurementSubmission(JSON.stringify(original))), normalized(original)) + assert.throws(() => submission.restoreProcurementSubmission('{}')) + assert.throws(() => submission.restoreProcurementSubmission(JSON.stringify({ ...original, currency: 'EUR' }))) + assert.equal(submission.definitelyRejected({ code: 'TIMEOUT' }), false) + assert.equal(submission.definitelyRejected({ status: 500 }), false) + assert.equal(submission.definitelyRejected({ status: 409 }), false) + assert.equal(submission.definitelyRejected({ status: 400 }), true) + assert.equal(submission.procurementRequestKey({ getRandomValues: target => { target.set([1, 2, 3, 4]); return target } }), 'proc-00000001000000020000000300000004') +}) + +class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } } +const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() } +function viewHarness({ createPage = false, detailPage = false, api = {}, storage = new Map() } = {}) { + const props = vue.reactive({ createPage, detailPage }) + const route = vue.reactive({ params: { orderNo: detailPage ? 'PO-1' : '' } }) + const auth = vue.reactive({ token: 'identity-A', user: { tenantName: 'Company' }, profile: { tenantName: 'Company' }, hasPermission: () => true }) + const pushes = [], dispose = [] + const defaults = { listProcurements: async () => ({ records: [], total: 0 }), getProcurement: async orderNo => ({ order: { orderNo } }), createProcurement: async () => ({ order: { orderNo: 'PO-CREATED' } }) } + const { descriptor } = parse(readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')) + const source = compileScript(descriptor, { id: 'procurement-test' }).content + const context = { exports: {}, Error, crypto, sessionStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key) }, + require(name) { + if (name === 'vue') return { ...vue, onBeforeUnmount: callback => dispose.push(callback), watch: (...args) => { const stop = vue.watch(...args); dispose.push(stop); return stop } } + if (name === 'vue-router') return { useRoute: () => route, useRouter: () => ({ push: async target => pushes.push(target) }) } + if (name === 'element-plus') return { ElMessage: { success: () => {} } } + if (name === '@/api/types') return { ApiError } + if (name === '@/api/session') return { accessTokenIdentity: token => token } + if (name === '@/modules/auth/public') return { useAuthStore: () => auth } + if (name === '../api/procurement') return { ...defaults, ...api } + if (name === '../procurementPresentation') return presentation + if (name === '../procurementSubmission') return submission + if (name.endsWith('.vue')) return { default: {} } + throw new Error(`unexpected view import ${name}`) + }, + } + runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText, context) + const state = context.exports.default.setup(props, { expose: () => {} }) + return { state, props, route, auth, pushes, storage, dispose: () => dispose.forEach(callback => callback()) } +} + +test('detail has one in-flight request and an older response cannot overwrite another order', async () => { + const calls = [], pending = new Map() + const harness = viewHarness({ detailPage: true, api: { getProcurement: orderNo => { calls.push(orderNo); return new Promise(resolve => pending.set(orderNo, resolve)) } } }) + try { + assert.deepEqual(calls, ['PO-1']) + await harness.state.loadDetail(); assert.equal(calls.length, 1) + harness.route.params.orderNo = 'PO-2'; await flush(); assert.deepEqual(calls, ['PO-1', 'PO-2']) + pending.get('PO-1')({ order: { orderNo: 'PO-1' } }); await flush(); assert.equal(harness.state.detail.value, null) + pending.get('PO-2')({ order: { orderNo: 'PO-2' } }); await flush(); assert.equal(harness.state.detail.value.order.orderNo, 'PO-2') + assert.equal(harness.state.loading.value, false) + } finally { harness.dispose() } +}) + +test('timeout retries the exact original body after reload and does not accept changed form data', async () => { + const storage = new Map(), calls = [] + const first = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } }) + try { + Object.assign(first.state.form, draft()); await first.state.submit(); assert.equal(calls.length, 1); assert.ok(first.state.pending.value) + first.state.form.productName = 'changed'; await first.state.submit(); assert.deepEqual(calls[1], calls[0]) + } finally { first.dispose() } + const recovered = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); return { order: { orderNo: 'PO-SAME' } } } } }) + try { + assert.equal(recovered.state.form.productName, 'Test product'); await recovered.state.submit(); assert.deepEqual(calls[2], calls[0]) + assert.equal(storage.size, 0); assert.equal(recovered.pushes[0].params.orderNo, 'PO-SAME') + } finally { recovered.dispose() } +}) + +test('submission double-click sends once and definitive validation rejection permits correction', async () => { + let reject, calls = 0 + const harness = viewHarness({ createPage: true, api: { createProcurement: () => { calls++; return new Promise((_, failure) => { reject = failure }) } } }) + try { + Object.assign(harness.state.form, draft()); const first = harness.state.submit(); await harness.state.submit(); assert.equal(calls, 1) + reject(new ApiError('invalid', 'A0422')); await first + assert.equal(harness.state.pending.value, null); assert.equal(harness.storage.size, 0) + } finally { harness.dispose() } +}) + +test('pagination batches one request and switching account clears old rows before the new response', async () => { + const calls = []; let resolveSecond + const harness = viewHarness({ api: { listProcurements: async params => { + calls.push(normalized(params)); if (calls.length < 3) return { records: [{ orderNo: 'PO-A' }], total: 100 } + return new Promise(resolve => { resolveSecond = resolve }) + } } }) + try { + await flush(); assert.equal(calls.length, 1) + harness.state.query.page = 2; harness.state.query.size = 50; await flush(); assert.equal(calls.length, 2) + harness.auth.token = 'identity-B'; await vue.nextTick(); assert.equal(harness.state.rows.value.length, 0); assert.equal(harness.state.total.value, 0) + resolveSecond({ records: [{ orderNo: 'PO-B' }], total: 1 }); await flush(); assert.equal(harness.state.rows.value[0].orderNo, 'PO-B') + assert.ok(calls.every(params => !('customerId' in params) && !('tenantId' in params))) + } finally { harness.dispose() } +}) + + +test('replay rejection cannot discard an earlier unknown creation outcome', async () => { + const calls = []; let attempt = 0 + const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { + calls.push(normalized(payload)); attempt++ + if (attempt === 1) throw new ApiError('timeout', 'TIMEOUT') + throw new ApiError('permission revoked', 'A0403') + } } }) + try { + Object.assign(harness.state.form, draft()); await harness.state.submit() + const saved = [...harness.storage.values()][0]; await harness.state.submit() + assert.equal([...harness.storage.values()][0], saved); assert.ok(harness.state.pending.value) + assert.deepEqual(calls[1], calls[0]) + } finally { harness.dispose() } +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts new file mode 100644 index 0000000..0e1e08c --- /dev/null +++ b/src/modules/cross-border/procurementPresentation.ts @@ -0,0 +1,123 @@ +import type { Amount, ProcurementCreate, ProcurementFee } from './api/procurement' + +export const procurementStatuses: Record = { + PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中', + WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中', + DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户', + REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭', +} +export const feePurposes: Record = { + SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费', + DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款', +} +export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' } +export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' { + if (status === 'SHIPPED_TO_CUSTOMER') return 'success' + if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger' + if (status === 'CANCELLED') return 'info' + if (status === 'INTERNATIONAL_TRANSIT') return 'primary' + return 'warning' +} +function cents(value: Amount | null | undefined): bigint | null { + if (value === null || value === undefined) return null + const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim()) + if (!match) return null + const result = BigInt(match[2]) * 100n + BigInt((match[3] || '').padEnd(2, '0')) + return match[1] ? -result : result +} +function decimal(value: bigint) { + const absolute = value < 0n ? -value : value + return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}` +} +export function money(value: Amount | null | undefined, currency: string | null | undefined = 'CNY') { + const amount = cents(value) + return amount === null ? '—' : `${currency || '—'} ${decimal(amount)}` +} +export function dateTime(value: string | null | undefined) { return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '—' } +export function measurement(value: Amount | null | undefined, scale: number) { + if (value === null || value === undefined) return '—' + const parts = /^(\d+)(?:\.(\d+))?$/.exec(String(value)) + if (!parts || (parts[2] || '').length > scale) return '待核对' + return `${parts[1]}.${(parts[2] || '').padEnd(scale, '0')}` +} +export function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) } +export function productHref(value: string | null | undefined) { + if (!value) return undefined + try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined } catch { return undefined } +} +export function feeStatus(fee: ProcurementFee) { + if (fee.purpose === 'INTERNATIONAL_ESTIMATE') { + const label: Record = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' } + if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus] + } + const labels: Record = { + FREEZE_PENDING: '冻结处理中', FREEZE_FAILED: '冻结未成功', FROZEN: '等待扣款', SETTLE_PENDING: '扣款处理中', SETTLE_ACTUAL_PENDING: '实际费用结算中', + SETTLE_FAILED: '扣款未成功', SETTLE_ACTUAL_FAILED: '实际费用未结清', SETTLED: '已扣款', RELEASE_PENDING: '释放处理中', RELEASE_FAILED: '释放未成功', + RELEASED: '已释放冻结', REFUND_PENDING: '退款处理中', REFUND_FAILED: '退款未成功', REFUNDED: '已退款', ZERO: '已确认零元', NOT_APPLICABLE: '不适用', TERMINATED: '已终止', + } + return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定' +} +export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | 'info' { + if (fee.walletStatus?.endsWith('_FAILED')) return 'danger' + if (['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE', 'RELEASED'].includes(fee.walletStatus || '')) return 'success' + if (fee.walletStatus === 'TERMINATED') return 'info' + return 'warning' +} +export function feeTotals(fees: ProcurementFee[]) { + let paid = 0n, refunded = 0n, estimatedHold = 0n + for (const fee of fees) { + const estimate = fee.purpose === 'INTERNATIONAL_ESTIMATE' + const include = (!estimate && fee.walletStatus === 'SETTLED') || fee.walletStatus === 'REFUNDED' + || (estimate && ['FROZEN', 'RELEASE_PENDING', 'RELEASE_FAILED'].includes(fee.walletStatus || '')) + if (!include) continue + const amount = cents(fee.amount) + if (amount === null || amount < 0n || fee.currency !== 'CNY') return null + if (estimate) estimatedHold += amount + else if (fee.walletStatus === 'REFUNDED') refunded += amount + else paid += amount + } + return { paid: decimal(paid), refunded: decimal(refunded), net: decimal(paid - refunded), estimatedHold: decimal(estimatedHold) } +} +export interface ProcurementDraft { + purchaseCategory: string + productName: string + productSpecification: string + productUrl: string + purchaseQuantity: string + budgetUnitPrice: string + currency: 'CNY' | 'USD' + receivingAddress: string + contactName: string + contactPhone: string + remark: string +} +export function emptyProcurementDraft(): ProcurementDraft { + return { purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: '1', budgetUnitPrice: '', currency: 'USD', receivingAddress: '', contactName: '', contactPhone: '', remark: '' } +} +export function draftBudgetTotal(draft: ProcurementDraft) { + const price = cents(draft.budgetUnitPrice) + if (price === null || price < 0n || !/^\d+$/.test(draft.purchaseQuantity)) return null + return decimal(price * BigInt(draft.purchaseQuantity)) +} +export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate { + for (const [field, label, max] of [ + ['purchaseCategory', '采购品类', 128], ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], + ['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64], + ] as const) { + if (!draft[field].trim() || draft[field].trim().length > max) throw new Error(`请填写有效的${label}`) + } + if (!customerName.trim()) throw new Error('当前企业信息尚未加载,请刷新登录信息后重试') + if (!/^\d+$/.test(draft.purchaseQuantity) || !Number.isSafeInteger(Number(draft.purchaseQuantity)) || Number(draft.purchaseQuantity) <= 0) throw new Error('采购数量必须为正整数') + const price = cents(draft.budgetUnitPrice) + if (price === null || price < 0n) throw new Error('请明确填写非负预算单价,最多两位小数;免费样品填写 0') + if (price > BigInt(Number.MAX_SAFE_INTEGER) || cents(Number(decimal(price))) !== price) throw new Error('预算金额过大,无法准确表示,请核对金额') + if (!['CNY', 'USD'].includes(draft.currency)) throw new Error('预算币种只支持 CNY 或 USD') + if (!/^[+0-9][0-9 ()-]{5,63}$/.test(draft.contactPhone.trim())) throw new Error('请填写快递可联系的电话号码,微信或邮箱不能代替电话') + if (draft.productUrl && (!productHref(draft.productUrl.trim()) || draft.productUrl.trim().length > 2000)) throw new Error('商品链接须为有效的 http 或 https 地址') + if (draft.productSpecification.length > 500 || draft.remark.length > 1000) throw new Error('商品规格或备注超过长度限制') + return { customerName: customerName.trim(), purchaseCategory: draft.purchaseCategory.trim(), productName: draft.productName.trim(), + productSpecification: draft.productSpecification.trim() || undefined, productUrl: draft.productUrl.trim() || undefined, + purchaseQuantity: Number(draft.purchaseQuantity), budgetUnitPrice: Number(decimal(price)), currency: draft.currency, + receivingAddress: draft.receivingAddress.trim(), contactName: draft.contactName.trim(), contactPhone: draft.contactPhone.trim(), + remark: draft.remark.trim() || undefined, idempotencyKey: key } +} diff --git a/src/modules/cross-border/procurementSubmission.ts b/src/modules/cross-border/procurementSubmission.ts new file mode 100644 index 0000000..3c4e9c4 --- /dev/null +++ b/src/modules/cross-border/procurementSubmission.ts @@ -0,0 +1,24 @@ +import type { ProcurementCreate } from './api/procurement' +import { buildProcurementCreate, emptyProcurementDraft } from './procurementPresentation' + +/** Restore the exact request; an unknown result cannot become a new order. */ +export function restoreProcurementSubmission(raw: string): ProcurementCreate { + const saved: unknown = JSON.parse(raw) + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error('提交记录格式错误') + const value = saved as Record + for (const key of ['customerName', 'purchaseCategory', 'productName', 'currency', 'receivingAddress', 'contactName', 'contactPhone', 'idempotencyKey']) if (typeof value[key] !== 'string') throw new Error('提交记录不完整') + if (typeof value.purchaseQuantity !== 'number' || typeof value.budgetUnitPrice !== 'number' || !String(value.idempotencyKey).trim() || String(value.idempotencyKey).length > 255) throw new Error('提交记录不完整') + for (const key of ['productSpecification', 'productUrl', 'remark']) if (value[key] !== undefined && typeof value[key] !== 'string') throw new Error('提交记录不完整') + if (value.currency !== 'CNY' && value.currency !== 'USD') throw new Error('提交记录币种无效') + return buildProcurementCreate({ ...emptyProcurementDraft(), purchaseCategory: String(value.purchaseCategory), productName: String(value.productName), + productSpecification: String(value.productSpecification || ''), productUrl: String(value.productUrl || ''), purchaseQuantity: String(value.purchaseQuantity), + budgetUnitPrice: String(value.budgetUnitPrice), currency: value.currency, receivingAddress: String(value.receivingAddress), contactName: String(value.contactName), + contactPhone: String(value.contactPhone), remark: String(value.remark || '') }, String(value.customerName), String(value.idempotencyKey)) +} +export function definitelyRejected(error: { code?: string; status?: number }) { + return ['A0400', 'A0401', 'A0403', 'A0422'].includes(error.code || '') || [400, 401, 403, 422].includes(error.status || 0) +} +export function procurementRequestKey(provider: Pick & Partial> = globalThis.crypto) { + if (provider.randomUUID) return provider.randomUUID() + return `proc-${Array.from(provider.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('')}` +} diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 72080f6..50b99f4 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,6 +1,187 @@ - + + + diff --git a/src/modules/overseas-warehouse/permissions.test.mjs b/src/modules/overseas-warehouse/permissions.test.mjs index 649278a..d055d90 100644 --- a/src/modules/overseas-warehouse/permissions.test.mjs +++ b/src/modules/overseas-warehouse/permissions.test.mjs @@ -42,7 +42,10 @@ test('opens warehouse service and return receipt details as dedicated protected test('returns to the labeling list after a labeling application is submitted', () => { assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) - assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + // Return-carton sources use the unified relabel application and its file endpoint. + assert.match(services, /returnCartonId: item\.returnCartonId/) + assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /申请已创建,附件待补传/) }) test('keeps overseas warehouse writes behind operation-specific button permissions', () => { diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index 94a9f3b..b66bbd7 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -6,7 +6,9 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url) const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8') test('贴标总文件与关联模板保持左右分组,退货来源使用专用明细', () => { - assert.match(view, /class="warehouse-file-groups" :class="\{ 'warehouse-file-groups--single': isReturnRelabelSource \}"/) + assert.match(view, /class="warehouse-file-groups(?:\s[^"]*)?"/) + assert.match(view, /\.warehouse-file-groups--compact\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/) + assert.match(view, /v-if="isReturnRelabelSource" class="warehouse-table-scroll"/) assert.match(view, /warehouse-service-page--return-relabel/) assert.match(view, /const sourceValue = `RETURN_ORDER:\$\{requestedReturnNo\}`/) }) @@ -136,7 +138,7 @@ test('仓储转运需要换标时上传总文件并挂到关联换标单', () => }) test('仓储转运换标总文件与明细模板在桌面端左右排列', () => { - assert.match(view, /class="warehouse-transfer-file-groups"/) + assert.match(view, /class="warehouse-transfer-file-groups(?:\s[^"]*)?"/) assert.match(view, /\.warehouse-transfer-file-groups\{display:grid;grid-template-columns:minmax\(0,1\.15fr\) minmax\(360px,\.85fr\)/) assert.match(view, /\.warehouse-transfer-total-files,\.warehouse-transfer-template-group\{height:100%;margin:0\}/) assert.match(view, /@media\(max-width:1180px\)\{\.warehouse-transfer-file-groups\{grid-template-columns:minmax\(0,1fr\)\}\}/) From 90e1e646ecd3f517acd55434aff87c54708ef093 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Sat, 10 Oct 2026 13:47:47 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/returnReceiptDisplay.test.mjs | 22 +++++++++++++++++++ .../utils/returnReceiptDisplay.ts | 21 ++++++++++++++++++ .../views/ReturnOrdersView.vue | 8 ++++--- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs create mode 100644 src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs new file mode 100644 index 0000000..3643b8e --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./returnReceiptDisplay.ts', import.meta.url), 'utf8') +const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText +const exports = {} +new Function('exports', compiled)(exports) + +test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => { + for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) { + assert.equal(exports.formatReturnVolume(input), expected) + } +}) + +test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => { + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626') + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '') + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123') + assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '') +}) diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts new file mode 100644 index 0000000..57e1087 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts @@ -0,0 +1,21 @@ +import type { ReturnReceipt } from '../api/returnOrders' + +export function receiptTrackingNo(receipt: Pick | Record) { + const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : '' + if (trackingNo) return trackingNo + const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : '' + return receipt.unitType === 'PARCEL' && code && !/^PK[0-9A-F]{16}$/.test(code) ? code : '' +} + +export function formatReturnVolume(value?: number | null) { + if (value == null || !Number.isFinite(value) || value < 0) return '—' + // Shift the decimal in text to avoid floating-point over-rounding exact thousandths. + const [coefficient, exponent = '0'] = String(value).toLowerCase().split('e') + const [integer, fraction = ''] = coefficient!.split('.') + const digits = integer! + fraction + const cutoff = integer!.length + Number(exponent) + 3 + const scaled = cutoff <= 0 ? '0' : digits.slice(0, cutoff).padEnd(cutoff, '0') + const remainder = cutoff < 0 ? digits : digits.slice(Math.max(0, cutoff)) + const rounded = BigInt(scaled) + (/[1-9]/.test(remainder) ? 1n : 0n) + return `${rounded / 1000n}.${String(rounded % 1000n).padStart(3, '0')}` +} diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index a646d48..770fb7e 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -11,6 +11,7 @@ import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-l import { createIdempotencyKey } from '@/utils/idempotencyKey' import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders' import { getReturnInventoryLabel } from '../api/returnInventory' +import { formatReturnVolume, receiptTrackingNo } from '../utils/returnReceiptDisplay' const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; returnNo?: string }>(), { createPage: false, detailPage: false, returnNo: '' }) const router = useRouter() @@ -67,7 +68,7 @@ const receiptColumns: DataTableColumn[] = [ { prop: 'receiptBatchNo', label: '批次号', minWidth: 145 }, { prop: 'receiptNo', label: '签收单号', minWidth: 145 }, { key: 'unitType', label: '实物类型', slotName: 'unitType', width: 90 }, - { prop: 'unitCode', label: '实物编码', minWidth: 160 }, + { key: 'unitCode', label: '实物编码', slotName: 'unitCode', minWidth: 160 }, { key: 'trackingNo', label: '快递单号', slotName: 'trackingNo', minWidth: 140 }, { key: 'startedBy', label: '开始方式', slotName: 'startedBy', width: 100 }, { prop: 'receivedQuantity', label: '实收', width: 80 }, @@ -389,7 +390,7 @@ onBeforeUnmount(clearPhotoUrls)

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ printableBoxCount }} 箱)

- +
长 {{ pallet.length ?? '—' }} cm · 宽 {{ pallet.width ?? '—' }} cm · 高 {{ pallet.height ?? '—' }} cm · 重量 {{ pallet.weight ?? '—' }} kg
@@ -437,7 +438,8 @@ onBeforeUnmount(clearPhotoUrls)

签收批次

- + + From 3692f9d27f5505f29f32135de2e0c3c79476121d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 00:24:21 +0800 Subject: [PATCH 9/9] fix: coalesce concurrent notification count reads --- .../notifications/notifications.test.mjs | 2 +- .../store/notifications.test.mjs | 49 +++++++++++++++++++ .../notifications/store/notifications.ts | 14 +++++- .../views/NotificationDetailView.vue | 2 +- .../notifications/views/NotificationsView.vue | 4 +- 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/modules/notifications/store/notifications.test.mjs diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs index 1c6adaf..462fc14 100644 --- a/src/modules/notifications/notifications.test.mjs +++ b/src/modules/notifications/notifications.test.mjs @@ -36,7 +36,7 @@ test('keeps customer message actions first and covers the requested local states assert.match(source, /notification-state--error/) assert.match(source, /暂无消息/) assert.doesNotMatch(source, /清空已读|删除消息/) - assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/) + assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(true\)\]\)/) assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/) assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/) assert.match(source, /v-if="canMarkAllRead"/) diff --git a/src/modules/notifications/store/notifications.test.mjs b/src/modules/notifications/store/notifications.test.mjs new file mode 100644 index 0000000..f41f6df --- /dev/null +++ b/src/modules/notifications/store/notifications.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { computed, ref } from 'vue' + +function setup() { + const requests = [] + const source = readFileSync(new URL('./notifications.ts', import.meta.url), 'utf8').replace(/^import [^\r\n]*\r?\n/gm, '').replace('export const', 'const') + const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const store = new Function('computed', 'ref', 'defineStore', 'getMyUnreadNotificationCount', code + '\nreturn useNotificationsStore()')(computed, ref, (_id, factory) => factory, () => new Promise((resolve, reject) => requests.push({ resolve, reject }))) + return { store, requests } +} + +test('shell and page concurrent reads share one pending request', async () => { + const { store, requests } = setup() + const a = store.refreshUnreadCount(), b = store.refreshUnreadCount() + assert.equal(requests.length, 1) + requests[0].resolve(5) + await Promise.all([a, b]) + assert.equal(store.unreadCount.value, 5) + assert.equal(store.loading.value, false) +}) + +test('post-mutation refresh reads again after a pending pre-mutation read', async () => { + const { store, requests } = setup() + const a = store.refreshUnreadCount(), b = store.refreshUnreadCount(true) + assert.equal(requests.length, 1) + requests[0].resolve(5) + await a + await Promise.resolve() + assert.equal(requests.length, 2) + requests[1].resolve(0) + await b + assert.equal(store.unreadCount.value, 0) +}) + +test('failed request releases the pending guard for retry', async () => { + const { store, requests } = setup() + const a = store.refreshUnreadCount() + requests[0].reject(new Error('network')) + await a + const b = store.refreshUnreadCount() + assert.equal(requests.length, 2) + requests[1].resolve(2) + await b + assert.equal(store.unreadCount.value, 2) + assert.equal(store.errorMessage.value, '') +}) diff --git a/src/modules/notifications/store/notifications.ts b/src/modules/notifications/store/notifications.ts index ebe0f92..d9514e0 100644 --- a/src/modules/notifications/store/notifications.ts +++ b/src/modules/notifications/store/notifications.ts @@ -9,7 +9,19 @@ export const useNotificationsStore = defineStore('client-notifications', () => { const hasUnread = computed(() => (unreadCount.value ?? 0) > 0) - async function refreshUnreadCount() { + let pending: Promise | null = null + + async function refreshUnreadCount(afterMutation = false): Promise { + if (pending) { + if (!afterMutation) return pending + await pending + return refreshUnreadCount() + } + pending = loadUnreadCount().finally(() => { pending = null }) + return pending + } + + async function loadUnreadCount() { loading.value = true errorMessage.value = '' try { diff --git a/src/modules/notifications/views/NotificationDetailView.vue b/src/modules/notifications/views/NotificationDetailView.vue index 9be7bcc..4593170 100644 --- a/src/modules/notifications/views/NotificationDetailView.vue +++ b/src/modules/notifications/views/NotificationDetailView.vue @@ -10,7 +10,7 @@ import type { NotificationDetail } from '../contracts' import { notificationCategoryLabel, notificationSentAt } from '../presentation' const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const notifications = useNotificationsStore(); const detail = ref(null); const loading = ref(false); const errorMessage = ref(''); const readErrorMessage = ref('') const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read')) -async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}} +async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount(true)}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}} async function load(){loading.value=true;errorMessage.value='';readErrorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));await markReadIfAllowed()}catch(error){detail.value=null;errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}} onMounted(()=>{void load()}) diff --git a/src/modules/notifications/views/NotificationsView.vue b/src/modules/notifications/views/NotificationsView.vue index 9f934b1..f128956 100644 --- a/src/modules/notifications/views/NotificationsView.vue +++ b/src/modules/notifications/views/NotificationsView.vue @@ -78,7 +78,7 @@ async function markRead(row: NotificationSummary) { if (!canMarkRead.value) return try { await markMyNotificationRead(row.id) - await Promise.all([load(), notifications.refreshUnreadCount()]) + await Promise.all([load(), notifications.refreshUnreadCount(true)]) } catch (error) { errorMessage.value = messageOf(error) } @@ -89,7 +89,7 @@ async function markAllRead() { markingAll.value = true try { await markAllMyNotificationsRead() - await Promise.all([load(), notifications.refreshUnreadCount()]) + await Promise.all([load(), notifications.refreshUnreadCount(true)]) } catch (error) { errorMessage.value = messageOf(error) } finally {