diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 9d24007..e16d21f 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -6,5 +6,5 @@ // biome-ignore lint: disable export {} declare global { - + const ElMessageBox: typeof import('element-plus/es').ElMessageBox } diff --git a/src/modules/cross-border/PROTOTYPE_MATRIX.md b/src/modules/cross-border/PROTOTYPE_MATRIX.md index e91ab40..17d7eb1 100644 --- a/src/modules/cross-border/PROTOTYPE_MATRIX.md +++ b/src/modules/cross-border/PROTOTYPE_MATRIX.md @@ -35,3 +35,16 @@ 2026-10-29 用户截图调整:采购待确认支持确认或必填原因驳回;四张实收/库存数量卡片置顶,商品及收件信息置底。本土卡车数量改托数,托数及预算须为正数。采购及卡车先等待Finance余额/授信冻结成功,再以同一幂等键建单;不足保留表单并提示原因。自动检查与人工视觉验收分别记录;待人工1920×1080、100%截图确认。 + +## 海外采购下单与结果处理(2026-10-30) + +依据:当前对话的下单页与详情页截图,以及用户明确要求“商品链接下单必填”“下单成功返回列表”“驳回/确认移至列表,弹窗显示照片和采购数量,驳回必填理由”“确认后两个按钮隐藏”。 + +| 页面 | 操作及承载 | 调整 | 验收 | +|---|---|---|---| +| 海外采购下单 | 既有独立页面 | 商品链接标记必填并校验非空及有效 HTTP/HTTPS 地址;成功返回采购列表,失败保留表单 | 定向自动测试;待人工截图确认 | +| 海外采购列表 | 待客户确认行的操作列 | 提供详情、驳回、确认采购结果;确认/驳回成功刷新列表,离开待客户确认状态后隐藏两项动作 | 状态与权限自动测试;待人工截图确认 | +| 采购结果确认/驳回 | 居中弹窗 | 显示单号、商品、采购数量、累计收货、逐批数量与可放大的收货照片;驳回原因必填,最多1000字;失败保留内容供重试 | 定向自动测试;待人工1920×1080、100%截图确认 | +| 海外采购详情 | 既有独立页面 | 移除结果确认与驳回按钮,保留详情和刷新 | 源码合同核对;待人工截图确认 | + +2026-10-30 照片布局按用户截图反馈收紧:收货批次按横向三列排列,照片缩略图为112×84,同批照片横向排列,超出可横向滚动;保留批次数量、时间和点击放大预览。待人工截图确认。 diff --git a/src/modules/cross-border/api/procurement.ts b/src/modules/cross-border/api/procurement.ts index 32fd12f..e81b6f2 100644 --- a/src/modules/cross-border/api/procurement.ts +++ b/src/modules/cross-border/api/procurement.ts @@ -12,6 +12,7 @@ export interface ProcurementSummary { budgetUnitPrice: Amount | null currency: string | null status: string + reviewRemark?: string | null version: number createdAt: string updatedAt: string @@ -25,6 +26,7 @@ export interface ProcurementOrder extends ProcurementSummary { plannedWarehouseName: string | null actualWarehouseCode: string | null actualWarehouseName: string | null + remark: string | null } export interface ProcurementFee { feeNo: string diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index ce6d659..62f1d20 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -75,7 +75,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.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity|reviewRemark/) + assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity/) + assert.match(procurementView, /label: '拒绝原因'/) + assert.match(procurementView, /reviewRemark/) assert.match(procurementView, /procurementStatuses/) assert.match(procurementView, /ProcurementDetailPanel/) }) diff --git a/src/modules/cross-border/components/ProcurementDecisionDialog.vue b/src/modules/cross-border/components/ProcurementDecisionDialog.vue new file mode 100644 index 0000000..2a34ce0 --- /dev/null +++ b/src/modules/cross-border/components/ProcurementDecisionDialog.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/modules/cross-border/components/ProcurementDetailPanel.vue b/src/modules/cross-border/components/ProcurementDetailPanel.vue index f934d4a..0ef785f 100644 --- a/src/modules/cross-border/components/ProcurementDetailPanel.vue +++ b/src/modules/cross-border/components/ProcurementDetailPanel.vue @@ -78,6 +78,7 @@ const totals = computed(() => feeTotals(props.detail.fees))
收件人
{{ detail.order.contactName }}
联系电话
{{ detail.order.contactPhone }}
收货地址
{{ detail.order.receivingAddress }}
+
采购备注
{{ detail.order.remark }}
商品链接
查看商品{{ text(detail.order.productUrl) }}
diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index 12648ce..5586584 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -24,7 +24,7 @@ const submission = moduleFrom(readFileSync(new URL('./procurementSubmission.ts', }) 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' }) + productUrl: 'https://example.com/product', 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', () => { @@ -100,7 +100,7 @@ test('procurement required fields validate individually and blank submissions ne const calls = [] const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(payload); return { order: { orderNo: 'PO-VALID' } } } } }) try { - const fields = ['procurementDirection', 'productName', 'purchaseQuantity', 'budgetUnitPrice', 'currency', 'contactName', 'contactPhone', 'receivingAddress'] + const fields = ['procurementDirection', 'productName', 'productUrl', 'purchaseQuantity', 'budgetUnitPrice', 'currency', 'contactName', 'contactPhone', 'receivingAddress'] const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') for (const field of fields) { assert.match(source, new RegExp(`]*prop="${field}"`)) @@ -116,6 +116,7 @@ test('procurement required fields validate individually and blank submissions ne await harness.state.submit() assert.equal(calls.length, 1) assert.equal(calls[0].budgetUnitPrice, 0) + assert.deepEqual(normalized(harness.pushes), [{ name: 'cross-border-procurement' }]) } finally { harness.dispose() } }) @@ -250,44 +251,16 @@ test('domestic purchase creates an export order and displays the actual origin a }) -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 }) - } } }) +test('unchanged create retries reuse the idempotency key', async () => { + const calls = [] + const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { calls.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } }) 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) + Object.assign(harness.state.form, draft()); await harness.state.submit(); await harness.state.submit() + assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey) + assert.equal(harness.pushes.length, 0) } 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')), '账单结算 · 待审核') @@ -308,15 +281,36 @@ test('insufficient balance and credit blocks procurement creation and preserves } finally { harness.dispose() } }) -test('customer rejection requires a reason and carries current order version', async () => { - const calls = [] - const harness = viewHarness({ detailPage: true, api: { rejectProcurement: async (orderNo, command) => { calls.push({ orderNo, ...command }); return { orderNo, status: 'RECEIVING', version: 2, rejectionReason: command.reason } } } }) +test('confirmation and rejection are list actions only while waiting for customer confirmation', async () => { + let status = 'WAIT_CUSTOMER_CONFIRM' + const harness = viewHarness({ api: { listProcurements: async () => ({ records: [{ orderNo: 'PO-1', status, purchaseQuantity: 10 }], total: 1 }) } }) try { - await flush(); await harness.state.rejectReceived() - assert.equal(calls.length, 1); assert.equal(calls[0].orderNo, 'PO-1'); assert.equal(calls[0].version, 1) - assert.equal(calls[0].reason, '照片不清晰'); assert.equal(harness.state.workflow.value.status, 'RECEIVING') + await flush() + const actions = harness.state.rowActions(harness.state.rows.value[0]) + assert.deepEqual(normalized(actions.map(action => action.key)), ['detail', 'reject', 'confirm']) + actions[1].run(); assert.equal(harness.state.decision.value, 'reject') + assert.equal(harness.state.decisionTarget.value.orderNo, 'PO-1') + status = 'CONFIRMED'; harness.state.decisionChanged(); await flush() + assert.equal(harness.state.decisionTarget.value, null) + assert.deepEqual(normalized(harness.state.rowActions(harness.state.rows.value[0]).map(action => action.key)), ['detail']) + harness.auth.hasPermission = () => false + assert.equal(harness.state.rowActions({ orderNo: 'PO-1', status: 'WAIT_CUSTOMER_CONFIRM' }).length, 1) + const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') + const detailHeader = source.slice(source.indexOf('
'), source.indexOf('
', source.indexOf('
'))) + assert.doesNotMatch(detailHeader, /确认采购结果|驳回/) + } finally { harness.dispose() } +}) + +test('product links must be nonblank and use HTTP or HTTPS before budget reservation', async () => { + for (const productUrl of ['', ' ', 'javascript:alert(1)', 'invalid', 'https://' ]) { + assert.throws(() => presentation.buildProcurementCreate({ ...draft(), productUrl }, 'Company', 'key'), /商品链接/) + } + let budgets = 0, creates = 0 + const harness = viewHarness({ createPage: true, api: { requireOrderBudget: async () => { budgets++ }, createProcurement: async () => { creates++ } } }) + try { + for (const productUrl of ['', ' ']) { + Object.assign(harness.state.form, draft(), { productUrl }); await harness.state.submit() + } + assert.equal(budgets, 0); assert.equal(creates, 0); assert.equal(harness.pushes.length, 0) } finally { harness.dispose() } - const blank = viewHarness({ detailPage: true, api: { rejectionReason: ' ', rejectProcurement: async () => { throw new Error('不得发送空原因') } } }) - try { await flush(); await blank.state.rejectReceived(); assert.match(blank.state.confirmError.value, /请填写驳回原因/) } - finally { blank.dispose() } }) diff --git a/src/modules/cross-border/procurementDecision.test.mjs b/src/modules/cross-border/procurementDecision.test.mjs new file mode 100644 index 0000000..ddfa76c --- /dev/null +++ b/src/modules/cross-border/procurementDecision.test.mjs @@ -0,0 +1,138 @@ +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' + +const source = readFileSync(new URL('./components/ProcurementDecisionDialog.vue', import.meta.url), 'utf8') +const { descriptor } = parse(source) +const code = ts.transpileModule(compileScript(descriptor, { id: 'decision-test' }).content, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText +const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() } +let key = 0 +function harness({ decision = 'confirm', api = {} } = {}) { + const props = vue.reactive({ order: { orderNo: 'PO-1', productName: '商品', purchaseQuantity: 10 }, decision }) + const auth = vue.reactive({ token: 'customer-A', hasPermission: () => true }) + const calls = [], events = [], disposers = [], created = [], revoked = [] + const workflow = { orderNo: 'PO-1', status: 'WAIT_CUSTOMER_CONFIRM', version: 7, receivedQuantity: 8, files: [{ fileId: 'F-1', fileName: 'photo.png' }], receipts: [{ id: 'R-1', quantity: 5, fileIds: ['F-1'], receivedAt: '' }, { id: 'R-2', quantity: 3, fileIds: ['F-2'], receivedAt: '' }] } + const defaults = { + getProcurementWorkflow: async () => workflow, + getProcurementFile: async () => ({ type: 'image/png' }), + confirmProcurement: async (orderNo, payload) => { calls.push({ orderNo, ...payload }); return { ...workflow, status: 'CONFIRMED' } }, + rejectProcurement: async (orderNo, payload) => { calls.push({ orderNo, ...payload }); return { ...workflow, status: 'RECEIVING' } }, + } + const context = { exports: {}, Error, URL: { createObjectURL: () => { const url = `blob:${created.length}`; created.push(url); return url }, revokeObjectURL: url => revoked.push(url) }, require(name) { + if (name === 'vue') return { ...vue, onBeforeUnmount: callback => disposers.push(callback) } + if (name === 'element-plus') return { ElMessage: { success: () => {} } } + 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 === '../procurementSubmission') return { procurementRequestKey: () => `key-${++key}` } + if (name === '../procurementPresentation') return { dateTime: value => value } + throw new Error(`unexpected import ${name}`) + } } + runInNewContext(code, context) + const state = context.exports.default.setup(props, { expose: () => {}, emit: (...args) => events.push(args) }) + return { state, auth, calls, events, created, revoked, workflow, dispose: () => disposers.forEach(callback => callback()) } +} + +test('both decisions load receipt photos and batch quantities with preview URL cleanup', async () => { + for (const decision of ['confirm', 'reject']) { + const h = harness({ decision }) + try { + await flush() + assert.equal(h.state.workflow.value.receivedQuantity, 8) + assert.equal(h.state.workflow.value.receipts.length, 2) + assert.equal(Object.keys(h.state.photos.value).length, 2) + assert.equal(h.state.canSubmit.value, true) + assert.equal(h.state.fileName('F-1'), 'photo.png') + assert.match(source, /order.purchaseQuantity/) + assert.match(source, /receipt.quantity/) + assert.match(source, / { + let finish, calls = [] + const h = harness({ api: { confirmProcurement: (orderNo, command) => { calls.push({ orderNo, ...command }); return new Promise(resolve => { finish = resolve }) } } }) + try { + await flush() + const pending = h.state.submitDecision(); await h.state.submitDecision() + assert.equal(calls.length, 1); assert.equal(calls[0].version, 7); assert.equal(calls[0].orderNo, 'PO-1') + finish({ status: 'CONFIRMED' }); await pending + assert.deepEqual(h.events, [['changed']]) + } finally { h.dispose() } +}) + +test('rejection requires a trimmed reason and retries unchanged payload with the same key', async () => { + const calls = [] + const h = harness({ decision: 'reject', api: { rejectProcurement: async (orderNo, command) => { calls.push({ orderNo, ...command }); throw new Error('网络失败') } } }) + try { + await flush() + for (const reason of ['', ' ', 'x'.repeat(1001)]) { + h.state.reason.value = reason; await h.state.submitDecision() + assert.match(h.state.error.value, /请填写驳回原因/) + } + assert.equal(calls.length, 0) + h.state.reason.value = ' 照片不清晰 ' + await h.state.submitDecision(); await h.state.submitDecision() + assert.equal(calls.length, 2); assert.equal(calls[0].reason, '照片不清晰'); assert.equal(calls[0].version, 7) + assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey) + assert.equal(h.state.reason.value, ' 照片不清晰 '); assert.equal(h.events.length, 0) + h.state.reason.value = '数量不符'; await h.state.submitDecision() + assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey) + } finally { h.dispose() } +}) + +test('confirmation retry preserves the loaded evidence and idempotency key', async () => { + const calls = [] + const h = harness({ api: { confirmProcurement: async (_, command) => { calls.push(command); throw new Error('版本已更新,请刷新') } } }) + try { + await flush(); await h.state.submitDecision(); await h.state.submitDecision() + assert.equal(calls.length, 2); assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey) + assert.equal(h.state.workflow.value.version, 7); assert.equal(Object.keys(h.state.photos.value).length, 2) + assert.equal(h.state.saving.value, false); assert.equal(h.events.length, 0) + } finally { h.dispose() } +}) + +test('successful rejection sends the trimmed reason and signals list refresh', async () => { + const h = harness({ decision: 'reject' }) + try { + await flush(); h.state.reason.value = ' 数量不符 '; await h.state.submitDecision() + assert.equal(h.calls[0].reason, '数量不符'); assert.equal(h.calls[0].version, 7) + assert.deepEqual(h.events, [['changed']]) + } finally { h.dispose() } +}) + +test('photo loading failures can be retried before a decision is submitted', async () => { + let fail = true + const h = harness({ api: { getProcurementFile: async () => { if (fail) throw new Error('failed'); return { type: 'image/png' } } } }) + try { + await flush(); await h.state.submitDecision() + assert.equal(h.calls.length, 0); assert.match(h.state.photoError.value, /照片加载失败/) + fail = false; await h.state.load(); await h.state.submitDecision() + assert.equal(h.calls.length, 1) + } finally { h.dispose() } +}) + +test('stale status, revoked permission and changed account cannot submit decisions', async () => { + const stale = harness({ api: { getProcurementWorkflow: async () => ({ status: 'CONFIRMED', receipts: [] }) } }) + try { await flush(); await stale.state.submitDecision(); assert.equal(stale.calls.length, 0) } finally { stale.dispose() } + const h = harness() + try { + await flush(); h.auth.hasPermission = () => false; await h.state.submitDecision(); assert.equal(h.calls.length, 0) + h.auth.hasPermission = () => true; h.auth.token = 'customer-B'; await h.state.submitDecision(); assert.equal(h.calls.length, 0) + } finally { h.dispose() } +}) + +test('late photo responses after closing do not allocate preview URLs', async () => { + const pending = [] + const h = harness({ api: { getProcurementFile: () => new Promise(resolve => pending.push(resolve)) } }) + await flush(); h.dispose() + pending.forEach(resolve => resolve({ type: 'image/png' })); await flush() + assert.equal(h.created.length, 0); assert.equal(h.events.length, 0) +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts index c3d0639..79a5b19 100644 --- a/src/modules/cross-border/procurementPresentation.ts +++ b/src/modules/cross-border/procurementPresentation.ts @@ -122,7 +122,8 @@ export function buildProcurementCreate(draft: ProcurementDraft, customerName: st 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.productUrl.trim()) throw new Error('请输入商品链接') + if (!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('商品规格或备注超过长度限制') if (draft.procurementDirection && !['OVERSEAS_TO_DOMESTIC', 'DOMESTIC_TO_OVERSEAS'].includes(draft.procurementDirection)) throw new Error('请选择有效采购方向') return { procurementDirection: draft.procurementDirection || 'OVERSEAS_TO_DOMESTIC', customerName: customerName.trim(), purchaseCategory: draft.purchaseCategory.trim(), productName: draft.productName.trim(), diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index b4ef7cb..8aa76df 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,7 +1,7 @@