diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index 8e8e0eb..7bbf4b4 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -163,3 +163,10 @@ test('replay rejection cannot discard an earlier unknown creation outcome', asyn assert.deepEqual(calls[1], calls[0]) } finally { harness.dispose() } }) + +test('purchase category is optional while its length remains bounded', () => { + const payload = presentation.buildProcurementCreate({ ...draft(), purchaseCategory: ' ' }, 'Company', 'key') + assert.equal(payload.purchaseCategory, '') + assert.equal(submission.restoreProcurementSubmission(JSON.stringify(payload)).purchaseCategory, '') + assert.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/) +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts index 0e1e08c..0bc9ec7 100644 --- a/src/modules/cross-border/procurementPresentation.ts +++ b/src/modules/cross-border/procurementPresentation.ts @@ -101,11 +101,12 @@ export function draftBudgetTotal(draft: ProcurementDraft) { } export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate { for (const [field, label, max] of [ - ['purchaseCategory', '采购品类', 128], ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], + ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], ['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64], ] as const) { if (!draft[field].trim() || draft[field].trim().length > max) throw new Error(`请填写有效的${label}`) } + if (draft.purchaseCategory.trim().length > 128) throw new Error('采购品类超过长度限制') 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) diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 50b99f4..86f7a35 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -147,7 +147,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })

商品与预算

- + @@ -158,12 +158,11 @@ onBeforeUnmount(() => { active = false; ++requestSequence })

最终国内收件资料

-
-
返回列表{{ pending ? '确认上次提交结果' : '提交采购订单' }}
+
{{ pending ? '确认上次提交结果' : '提交采购订单' }}
diff --git a/src/modules/dashboard/views/DashboardView.vue b/src/modules/dashboard/views/DashboardView.vue index 21d97b3..3f32c2c 100644 --- a/src/modules/dashboard/views/DashboardView.vue +++ b/src/modules/dashboard/views/DashboardView.vue @@ -13,8 +13,8 @@ const primaryActions = computed(() => [ { title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false }, { title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false }, { title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false }, - { title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer', permission: 'merchant:overseas-warehouse:transfer:list', tone: 'teal', featured: false, actionLabel: '开始办理' }, - { title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal', permission: 'merchant:overseas-warehouse:discard:list', tone: 'brown', featured: false, actionLabel: '开始办理' }, + { title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer/create', permission: 'merchant:overseas-warehouse:transfer:create', tone: 'teal', featured: false, actionLabel: '开始办理' }, + { title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal/create', permission: 'merchant:overseas-warehouse:discard:create', tone: 'brown', featured: false, actionLabel: '开始办理' }, ].filter((action) => auth.hasPermission(action.permission))) diff --git a/src/modules/first-leg/api/chargeableWeights.test.mjs b/src/modules/first-leg/api/chargeableWeights.test.mjs new file mode 100644 index 0000000..3c60a8d --- /dev/null +++ b/src/modules/first-leg/api/chargeableWeights.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +test('计费重确认保留大整数来源和原幂等键,只提交预期重量版本', async () => { + const calls = [] + const source = readFileSync(new URL('./chargeableWeights.ts', import.meta.url), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText + const exports = {} + new Function('require', 'exports', code)(() => ({ apiRequest: async request => { calls.push(request); return [] } }), exports) + const id = '9223372036854775806' + await exports.getChargeableWeights(id) + await exports.confirmChargeableWeight({ inboundId: id, fingerprint: 'abc' }, 'original-command') + assert.equal(calls[0].url, `/merchant/v1/first-leg/orders/${id}/chargeable-weights`) + assert.equal(calls[1].url, `/merchant/v1/first-leg/chargeable-weights/${id}/confirm`) + assert.deepEqual(calls[1].headers, { 'Idempotency-Key': 'original-command' }) + assert.deepEqual(calls[1].data, { expectedFingerprint: 'abc' }) + assert.equal(calls.some(call => call.url.includes('inbound-billing')), false) +}) diff --git a/src/modules/first-leg/api/chargeableWeights.ts b/src/modules/first-leg/api/chargeableWeights.ts new file mode 100644 index 0000000..f34ce49 --- /dev/null +++ b/src/modules/first-leg/api/chargeableWeights.ts @@ -0,0 +1,16 @@ +import { apiRequest } from '@/api/http' +export interface ChargeableWeight { + inboundId: string + orderId: string + chargeableWeightKg: number + fingerprint: string + confirmed: boolean + confirmedAt: string | null +} +export function getChargeableWeights(orderId: string) { + return apiRequest({ method: 'GET', url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/chargeable-weights` }) +} +export function confirmChargeableWeight(weight: ChargeableWeight, key: string) { + return apiRequest({ method: 'POST', url: `/merchant/v1/first-leg/chargeable-weights/${encodeURIComponent(weight.inboundId)}/confirm`, + headers: { 'Idempotency-Key': key }, data: { expectedFingerprint: weight.fingerprint } }) +} diff --git a/src/modules/first-leg/components/ChargeableWeightDialog.test.mjs b/src/modules/first-leg/components/ChargeableWeightDialog.test.mjs new file mode 100644 index 0000000..2d09f05 --- /dev/null +++ b/src/modules/first-leg/components/ChargeableWeightDialog.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { ref } from 'vue' + +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() } +function harness(api, props = { orderId: 'one', canConfirm: true }) { + let changed, disposed + const events = [] + const source = readFileSync(new URL('./ChargeableWeightDialog.vue', import.meta.url), 'utf8').split('')[0] + const code = ts.transpileModule(source + '\nexports.state = { rows, loading, saving, error, confirm, load };', { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText + const exports = {} + new Function('require', 'exports', 'defineProps', 'defineEmits', code)(name => { + if (name === 'vue') return { ref, watch: (_, callback) => { changed = callback; callback() }, onBeforeUnmount: callback => { disposed = callback } } + if (name === 'element-plus') return { ElMessage: { success() {} } } + return api + }, exports, () => props, () => name => events.push(name)) + return { ...exports.state, props, change: () => changed(), dispose: () => disposed(), events } +} + +test('确认结果未知时重试原命令,成功后回查并通知工作台刷新', async () => { + const row = { inboundId: '100', fingerprint: 'weight-v1', chargeableWeightKg: 50, confirmed: false } + const commands = [] + let fail = true + const h = harness({ getChargeableWeights: async () => [row], confirmChargeableWeight: async (_, key) => { + commands.push(key); if (fail) throw new Error('响应丢失'); row.confirmed = true + } }) + await flush(); await h.confirm(row) + assert.equal(h.error.value, '响应丢失'); assert.equal(h.saving.value, '') + fail = false; await h.confirm(row) + assert.equal(commands[0], commands[1]); assert.deepEqual(h.events, ['confirmed']) + assert.equal(h.rows.value[0].confirmed, true); assert.equal(h.saving.value, '') +}) + +test('切换订单或关闭后丢弃迟到的读取结果,无权限不能确认', async () => { + let resolveOld + const h = harness({ getChargeableWeights: order => order === 'one' ? new Promise(resolve => { resolveOld = resolve }) : Promise.resolve([]), + confirmChargeableWeight: () => { throw new Error('无权限不得写入') } }, { orderId: 'one', canConfirm: false }) + h.props.orderId = 'two'; h.change(); await flush() + resolveOld([{ inboundId: 'old' }]); await flush() + assert.deepEqual(h.rows.value, []) + await h.confirm({ inboundId: 'old', fingerprint: 'old' }) + assert.deepEqual(h.events, []) + h.dispose() +}) diff --git a/src/modules/first-leg/components/ChargeableWeightDialog.vue b/src/modules/first-leg/components/ChargeableWeightDialog.vue new file mode 100644 index 0000000..f8ab6a8 --- /dev/null +++ b/src/modules/first-leg/components/ChargeableWeightDialog.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index d6ebbff..868609c 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -20,7 +20,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v assert.match(source, /watch\(\(\) => props\.autoOpen/) assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/) assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/) - assert.match(source, /平台推送计费重后,可在这里核对并确认/) + assert.match(source, /平台推送费用后,可在这里核对并确认/) assert.match(source, /@closed="emit\('closed'\)"/) }) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index 1027174..dfc2873 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -198,19 +198,19 @@ async function confirmFee() { - +
- 正在加载计费重 + 正在加载费用
-
{{ props.unavailableMessage }}平台推送计费重后,可在这里核对并确认。
+
{{ props.unavailableMessage }}平台推送费用后,可在这里核对并确认。