From fe6d925e7cd8abc7db7711d4d976942a150bcda7 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 26 Oct 2026 13:43:20 +0800 Subject: [PATCH 01/29] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/cross-border/api/procurement.ts | 2 ++ .../components/ProcurementDetailPanel.vue | 17 ++++++++-------- src/modules/cross-border/procurement.test.mjs | 15 ++++++++++++++ .../cross-border/procurementPresentation.ts | 20 +++++++++++++++---- .../cross-border/views/ProcurementView.vue | 17 +++++++++------- 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/modules/cross-border/api/procurement.ts b/src/modules/cross-border/api/procurement.ts index 6b1b6ca..29db3c9 100644 --- a/src/modules/cross-border/api/procurement.ts +++ b/src/modules/cross-border/api/procurement.ts @@ -3,6 +3,7 @@ import type { PageResult } from './businessOrders' export type Amount = number | string export interface ProcurementSummary { + procurementDirection?: import('../procurementPresentation').ProcurementDirection orderNo: string purchaseCategory: string productName: string @@ -73,6 +74,7 @@ export interface ProcurementDetail { exceptionSummaries: string[] } export interface ProcurementCreate { + procurementDirection?: import('../procurementPresentation').ProcurementDirection customerName: string purchaseCategory: string productName: string diff --git a/src/modules/cross-border/components/ProcurementDetailPanel.vue b/src/modules/cross-border/components/ProcurementDetailPanel.vue index 349e6fa..1f8cd42 100644 --- a/src/modules/cross-border/components/ProcurementDetailPanel.vue +++ b/src/modules/cross-border/components/ProcurementDetailPanel.vue @@ -1,9 +1,10 @@ diff --git a/src/modules/cross-border/components/TransportDemandForm.vue b/src/modules/cross-border/components/TransportDemandForm.vue index 460c928..cc02418 100644 --- a/src/modules/cross-border/components/TransportDemandForm.vue +++ b/src/modules/cross-border/components/TransportDemandForm.vue @@ -29,14 +29,14 @@ defineExpose({ - - - - - - - - + + + + + + + +
@@ -58,8 +58,8 @@ defineExpose({
- - + +
diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index d369664..c9c865a 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -65,14 +65,14 @@ function viewHarness({ createPage = false, detailPage = false, api = {}, storage 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 defaults = { getProcurementWorkflow: async orderNo => ({ orderNo, version: 1, status: 'WAIT_CUSTOMER_CONFIRM', receipts: [], outbounds: [] }), confirmProcurement: async () => ({ orderNo: 'PO-1', version: 2, status: 'CONFIRMED' }), 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 === 'element-plus') return { ElMessage: { success: () => {} }, ElMessageBox: { confirm: async () => {} } } if (name === '@/api/types') return { ApiError } if (name === '@/api/session') return { accessTokenIdentity: token => token } if (name === '@/modules/auth/public') return { useAuthStore: () => auth } @@ -160,12 +160,60 @@ test('domestic purchase creates an export order and displays the actual origin a const payload = presentation.buildProcurementCreate({ ...draft(), procurementDirection: 'DOMESTIC_TO_OVERSEAS', receivingAddress: 'US, CA, Los Angeles, test destination' }, 'Authenticated Company', 'export-key') assert.equal(payload.procurementDirection, 'DOMESTIC_TO_OVERSEAS') assert.equal(payload.receivingAddress, 'US, CA, Los Angeles, test destination') - assert.equal(presentation.statusLabel('WAIT_OVERSEAS_RECEIPT', payload.procurementDirection), '待国内到仓') - assert.equal(presentation.statusLabel('DOMESTIC_RECEIVING', payload.procurementDirection), '海外收货中') - assert.equal(presentation.statusLabel('WAIT_CHARGES', payload.procurementDirection), '待海外核费') - assert.equal(presentation.statusLabel('WAIT_OVERSEAS_RECEIPT'), '待海外到仓') + assert.equal(presentation.statusLabel('RECEIVING', payload.procurementDirection), '收货中') + assert.equal(presentation.statusLabel('DELIVERING', payload.procurementDirection), '派送中') + assert.equal(presentation.statusLabel('WAIT_CUSTOMER_CONFIRM', payload.procurementDirection), '待客户确认') + assert.equal(presentation.statusLabel('COMPLETED'), '签收完成') assert.throws(() => presentation.buildProcurementCreate({ ...draft(), procurementDirection: 'INVALID' }, 'Company', 'key'), /采购方向/) const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') assert.match(source, /v-model="form.procurementDirection"/) assert.match(source, /regionText\('最终国内收件资料', form.procurementDirection\)/) }) + + +test('customer confirms with current workflow version and double-click only sends once', async () => { + let resolve, calls = [] + const harness = viewHarness({ detailPage: true, api: { confirmProcurement: (orderNo, payload) => { + calls.push({ orderNo, ...payload }); return new Promise(done => { resolve = done }) + } } }) + try { + await flush() + const first = harness.state.confirmReceived(); await flush(); await harness.state.confirmReceived() + assert.equal(calls.length, 1); assert.equal(calls[0].version, 1); assert.equal(calls[0].orderNo, 'PO-1') + assert.ok(calls[0].idempotencyKey) + resolve({ orderNo: 'PO-1', version: 2, status: 'CONFIRMED' }); await first + assert.equal(harness.state.workflow.value.status, 'CONFIRMED'); assert.equal(harness.state.canConfirm.value, false) + } finally { harness.dispose() } +}) + +test('failed customer confirmation preserves receipt context for recovery', async () => { + const harness = viewHarness({ detailPage: true, api: { confirmProcurement: async () => { throw new ApiError('版本已更新,请刷新', 'A0409') } } }) + try { + await flush(); await harness.state.confirmReceived() + assert.equal(harness.state.workflow.value.status, 'WAIT_CUSTOMER_CONFIRM') + assert.equal(harness.state.confirming.value, false); assert.equal(harness.state.confirmError.value, '版本已更新,请刷新') + } finally { harness.dispose() } +}) + + +test('unknown create and confirmation results reuse idempotency keys for unchanged retries', async () => { + const creates = [], confirms = [] + const create = viewHarness({ createPage: true, api: { createProcurement: async payload => { creates.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } }) + const confirm = viewHarness({ detailPage: true, api: { confirmProcurement: async (_, payload) => { confirms.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } }) + try { + Object.assign(create.state.form, draft()); await create.state.submit(); await create.state.submit() + assert.equal(creates[0].idempotencyKey, creates[1].idempotencyKey) + await flush(); await confirm.state.confirmReceived(); await confirm.state.confirmReceived() + assert.equal(confirms[0].idempotencyKey, confirms[1].idempotencyKey) + } finally { create.dispose(); confirm.dispose() } +}) + + +test('bill-settled manual fees show review results without counting a balance debit', () => { + const bill = determination => ({ ...fee('OTHER', 'NOT_REQUIRED', '100'), determination }) + assert.equal(presentation.feeStatus(bill('PENDING')), '账单结算 · 待审核') + assert.equal(presentation.feeStatus(bill('APPROVED')), '账单结算 · 已确认') + assert.equal(presentation.feeStatus(bill('REJECTED')), '账单结算 · 已拒绝') + assert.equal(presentation.feeTone(bill('REJECTED')), 'danger') + assert.equal(presentation.feeTotals([bill('PENDING'), bill('APPROVED'), bill('REJECTED')]).paid, '0.00') +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts index 57f7282..5290def 100644 --- a/src/modules/cross-border/procurementPresentation.ts +++ b/src/modules/cross-border/procurementPresentation.ts @@ -1,9 +1,8 @@ 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: '已寄客户', + PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认', + IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成', REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭', } export const feePurposes: Record = { @@ -12,10 +11,10 @@ export const feePurposes: Record = { } export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) } export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' { - if (status === 'SHIPPED_TO_CUSTOMER') return 'success' + if (status === 'COMPLETED' || status === 'CONFIRMED') return 'success' if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger' if (status === 'CANCELLED') return 'info' - if (status === 'INTERNATIONAL_TRANSIT') return 'primary' + if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary' return 'warning' } function cents(value: Amount | null | undefined): bigint | null { @@ -46,6 +45,12 @@ export function productHref(value: string | null | 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.walletStatus === 'NOT_REQUIRED') { + if (fee.determination === 'PENDING') return '账单结算 · 待审核' + if (fee.determination === 'APPROVED') return '账单结算 · 已确认' + if (fee.determination === 'REJECTED') return '账单结算 · 已拒绝' + return '账单结算' + } if (fee.purpose === 'INTERNATIONAL_ESTIMATE') { const label: Record = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' } if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus] @@ -58,6 +63,8 @@ export function feeStatus(fee: ProcurementFee) { return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定' } export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | 'info' { + if (fee.determination === 'REJECTED') return 'danger' + if (fee.walletStatus === 'NOT_REQUIRED' && fee.determination === 'APPROVED') return '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' diff --git a/src/modules/cross-border/transportDemandForm.ts b/src/modules/cross-border/transportDemandForm.ts index 91d5f31..b235b6a 100644 --- a/src/modules/cross-border/transportDemandForm.ts +++ b/src/modules/cross-border/transportDemandForm.ts @@ -32,10 +32,14 @@ export function createTransportDemandForm(): TransportDemandForm { } } -const requiredText = { - origin: '起运地', destination: '目的地', originContactName: '起运地联系人', - originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话', -} as const +const requiredText = (kind: TransportDemandKind) => ({ + origin: kind === 'truck' ? 'B点取货地' : '起运地', + destination: kind === 'truck' ? 'C点送达地' : '目的地', + originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人', + originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话', + destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人', + destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话', +} as const) export function localMinuteInput(value: unknown) { return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : '' @@ -43,8 +47,9 @@ export function localMinuteInput(value: unknown) { export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules { const rules: FormRules = {} - for (const [field, label] of Object.entries(requiredText)) { - rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] + const labels = requiredText(kind) + for (const [field, label] of Object.entries(labels)) { + rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] } if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }] for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) { @@ -52,23 +57,26 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }] } rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }] - rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }] - rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, { - validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change', + const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间' + rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }] + rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, { + validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change', }] return rules } export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined { if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型' - for (const [field, label] of Object.entries(requiredText)) { - if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}` + const labels = requiredText(kind) + for (const [field, label] of Object.entries(labels)) { + if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}` } - for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) { + const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间' + for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) { if (!form[field]) return `请选择${label}` if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}` } - if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间' + if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return `${deliveryLabel}不能早于${pickupLabel}` for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) { if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字` } diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 66695ed..0d010ae 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,7 +1,7 @@