diff --git a/docs/business-index.md b/docs/business-index.md index 8a89718..2d96f1b 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -62,3 +62,4 @@ - 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。 - 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。 - 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。 +- 排车拣货、普通出库、整托出库和废弃确认在实际写库存前,先用原扫码 `idempotencyKey` 调用 `POST /api/pda/v1/last-leg/storage-rent/preparations`。四种模式分别为 `DISPATCH/OUTBOUND/OUTBOUND_PALLET/DISCARD`,货源、客户、计租范围和可用资金由服务端校验。仅明确 `ready=true` 且状态为 `NOT_REQUIRED/READY/CONSUMED` 才继续原扫码命令;待冻结、失败、过期、响应异常或网络未知均不发库存写请求。保留完整原命令与键,再次提交同一预校验接口恢复,不能换新键绕过费用确认;已消费的预校验只用于恢复原命令结果,不重复收费。定向回归为 `tests/storage-rent-preflight.test.mjs`,离线通过不代表实际冻结、扣款和出库联调已验收。 diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js index 2c32600..bc56f24 100644 --- a/src/api/fulfillment.js +++ b/src/api/fulfillment.js @@ -3,6 +3,7 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js const DISPATCH_BASE = '/pda/v1/last-leg/dispatches' const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks' const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards' +const STORAGE_PREPARATION_BASE = '/pda/v1/last-leg/storage-rent/preparations' const encoded = (value) => encodeURIComponent(String(value || '').trim()) @@ -97,7 +98,30 @@ function dispatchWrite(path, request) { requireDispatchVersion(request?.recordVersion) return overseasRequest('POST', path, request) } -export function pickDispatchItem(dispatchNo, dispatchItemId, request) { +async function prepareStorageRent(mode, businessNo, request, extra = {}) { + try { + if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务') + const result = await overseasRequest('POST', STORAGE_PREPARATION_BASE, { + mode, businessNo, idempotencyKey: request.idempotencyKey, ...extra + }) + if (result?.ready !== true || !['NOT_REQUIRED', 'READY', 'CONSUMED'].includes(result?.status)) { + const error = new Error(result?.message || (result?.status === 'FREEZE_PENDING' ? '费用确认中,请稍后重试' : '费用尚未确认,请稍后重试')) + error.code = `STORAGE_RENT_${result?.status || 'INVALID_RESPONSE'}` + throw error + } + } catch (cause) { + // 预扣可能已受理,保留完整原请求及幂等键继续核对。 + cause.storageRentPreparation = true + throw cause + } +} + +export async function pickDispatchItem(dispatchNo, dispatchItemId, request) { + requireDispatchVersion(request?.recordVersion) + await prepareStorageRent('DISPATCH', dispatchNo, request, { + itemId: dispatchItemId, positionId: request.positionId, packages: request.packages, + cartonMark: request.cartonMark, productQuantity: request.productQuantity + }) return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request) } export function completeDispatchLoading(dispatchNo, request) { @@ -120,11 +144,17 @@ export function resolveOutboundTask(businessNo) { return overseasRequest('GET', `${OUTBOUND_BASE}/resolve/${encoded(businessNo)}`) } -export function pickOutboundTask(taskNo, request) { +export async function pickOutboundTask(taskNo, request) { + await prepareStorageRent('OUTBOUND', taskNo, request, { + itemId: request.outboundPalletItemId, positionId: request.inventoryPositionId, + packages: request.packages, cartonMarks: request.cartonMarks, + skuCode: request.skuCode, productQuantity: request.productQuantity + }) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request) } -export function pickOutboundPallet(taskNo, palletNo, request) { +export async function pickOutboundPallet(taskNo, palletNo, request) { + await prepareStorageRent('OUTBOUND_PALLET', taskNo, request, { palletNo }) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request) } @@ -176,8 +206,10 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) { return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo, ...(discardNo ? { discardNo } : {}) })) } -export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false) { - return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds, scannedCartonMarks, confirmAllCartons })) +export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) { + const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey } + await prepareStorageRent('DISCARD', discardNo, request, { palletNo, lines, photoFileIds, scannedCartonMarks, confirmAllCartons }) + return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body)) } const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files` diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue index 66d6be5..e60914d 100644 --- a/src/pages/dispatch/dispatch.vue +++ b/src/pages/dispatch/dispatch.vue @@ -147,7 +147,7 @@ export default { await this.recoverCommand() } catch (cause) { this.setError(cause, '理货审核未提交') } }, - async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? latest.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) }, + async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && !cause.storageRentPreparation && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? latest.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) }, discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() }, async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() }, async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } }, diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue index 439cf9d..93e09d9 100644 --- a/src/pages/outbound/outbound.vue +++ b/src/pages/outbound/outbound.vue @@ -196,7 +196,7 @@ // 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。 validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) }, updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } }, - async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId)), this.discardScannedMarks, this.discardAllCartons); this.successMessage = '废弃出库作业及照片已提交,已完成'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } }, + async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.error = null; this.successMessage = ''; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId)); const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo: this.discardCurrent.discardNo, palletNo: this.discardTask.palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: this.discardAllCartons }); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, photoFileIds, this.discardScannedMarks, this.discardAllCartons, { idempotencyKey: key.value }); releaseFulfillmentKey('outbound', key.signature); this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); this.successMessage = '废弃出库作业及照片已提交,已完成'; playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } }, handleScan(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code }) diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs index 6e0cf4e..13d908e 100644 --- a/tests/fulfillment-workflows.test.mjs +++ b/tests/fulfillment-workflows.test.mjs @@ -107,6 +107,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) let data = {} + if (options.url.endsWith('/storage-rent/preparations')) data = { status: 'READY', ready: true } if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = [] else if (options.method === 'GET' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = [] else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] } @@ -126,7 +127,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' }) await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' }) - assert.equal(calls.length, 13) + assert.equal(calls.length, 16) assert.deepEqual(calls.find(call => call.url.endsWith('/dispatches/DSP%2F1/loading-review')).data, { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' }) assert.ok(calls.some((call) => call.url.endsWith('/dispatches'))) assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick'))) diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs index 1a6f0e4..d22d111 100644 --- a/tests/outbound-lifecycle.test.mjs +++ b/tests/outbound-lifecycle.test.mjs @@ -254,5 +254,31 @@ test('partial bound pallet confirmation sends only selected marks and preserves await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true) await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1') await vm.confirmDiscard() - assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false]) + assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }]) +}) + +test('discard confirmation retains its original request key until a definite successful response', async () => { + const released = []; const submitted = [] + const retry = deferred() + let attempt = 0 + const { vm } = page({ + confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('费用确认中'); await retry.promise }, + releaseFulfillmentKey: (...args) => released.push(args), listCargoDiscardTasks: async () => [] + }) + vm.discardCurrent = { discardNo: 'CD-RETRY' } + vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] } + vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }] + await vm.confirmDiscard() + assert.equal(released.length, 0) + assert.ok(vm.discardTask) + assert.equal(vm.error.message, '费用确认中') + vm.successMessage = '上次作业已完成' + const retried = vm.confirmDiscard() + assert.equal(vm.error, null) + assert.equal(vm.successMessage, '') + retry.resolve(); await retried + assert.deepEqual(submitted[0], submitted[1]) + assert.deepEqual(released, [['outbound', 'signature']]) + assert.equal(vm.error, null) + assert.match(vm.successMessage, /已完成/) }) diff --git a/tests/storage-rent-preflight.test.mjs b/tests/storage-rent-preflight.test.mjs new file mode 100644 index 0000000..52e6b9a --- /dev/null +++ b/tests/storage-rent-preflight.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js' +import { session } from '../src/stores/session.js' + +function transport(reply) { + const calls = [] + session.apiBase = '/api'; session.token = 'overseas-token' + globalThis.uni = { request(options) { + calls.push({ url: options.url, data: options.data }) + if (reply instanceof Error) return options.fail({ errMsg: reply.message }) + options.success({ statusCode: 200, data: { code: '200', data: options.url.endsWith('/preparations') ? reply : { success: true } }, header: {} }) + } } + return calls +} + +test('all stock scan modes require ready preparation with the original command key and source identifiers', async () => { + const calls = transport({ status: 'READY', ready: true }) + const request = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 2, cartonMark: 'RC-1', productQuantity: 7, idempotencyKey: 'original-dispatch-key' } + await pickDispatchItem('DSP-1', '9007199254740995', request) + assert.deepEqual(calls[0].data, { mode: 'DISPATCH', businessNo: 'DSP-1', itemId: '9007199254740995', positionId: request.positionId, packages: 2, cartonMark: 'RC-1', productQuantity: 7, idempotencyKey: request.idempotencyKey }) + assert.deepEqual(calls[1].data, request) + await pickOutboundTask('TASK-1', { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'original-sku-key' }) + assert.deepEqual(calls[2].data, { mode: 'OUTBOUND', businessNo: 'TASK-1', itemId: '9007199254740996', positionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'original-sku-key' }) + await pickOutboundPallet('TASK-1', 'DP-1', { idempotencyKey: 'original-pallet-key' }) + assert.deepEqual(calls[4].data, { mode: 'OUTBOUND_PALLET', businessNo: 'TASK-1', palletNo: 'DP-1', idempotencyKey: 'original-pallet-key' }) + assert.equal(calls.length, 6) + for (let i = 0; i < calls.length; i += 2) assert.ok(calls[i].url.endsWith('/preparations')) +}) + +test('carton outbound preparation uses the same identity as the backend stock command', async () => { + const calls = transport({ status: 'READY', ready: true }) + const request = { outboundPalletItemId: '21', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], idempotencyKey: 'carton-key' } + await pickOutboundTask('TASK-2', request) + assert.deepEqual(calls[0].data.cartonMarks, request.cartonMarks) + // 出库后台消费只使用 cartonMarks,不能额外派生 cartonMark 导致幂等摘要不一致。 + assert.equal(Object.hasOwn(calls[0].data, 'cartonMark'), false) + assert.deepEqual(calls[1].data, request) +}) + +test('pending, failed, expired, malformed and network responses never send a stock mutation', async () => { + for (const response of [{ status: 'FREEZE_PENDING', ready: false }, { status: 'FAILED', message: '余额及授信不足' }, { status: 'EXPIRED', ready: false }, { status: 'READY', ready: false }, { status: 'UNKNOWN', ready: true }, {}, new Error('network down')]) { + const calls = transport(response) + await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', { idempotencyKey: 'stable-key' }), error => error.storageRentPreparation === true) + assert.equal(calls.length, 1) + } +}) + +test('retry reuses the preparation key; exempt and already consumed commands can recover the stock result', async () => { + const request = { idempotencyKey: 'stable-key' } + let calls = transport({ status: 'FREEZE_PENDING', ready: false }) + await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', request)) + const original = calls[0].data + for (const status of ['NOT_REQUIRED', 'READY', 'CONSUMED']) { + calls = transport({ status, ready: true }) + await pickOutboundPallet('TASK-1', 'DP-1', request) + assert.deepEqual(calls[0].data, original) + assert.equal(calls.length, 2) + } + const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8') + assert.match(page, /!cause\.storageRentPreparation/) +}) + +test('invalid dispatch version or missing command identity cannot start a reservation', async () => { + const calls = transport({ status: 'READY', ready: true }) + await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/) + await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/) + assert.equal(calls.length, 0) +}) + +test('discard requires funds before stock confirmation and preserves the complete source payload on retry', async () => { + const lines = [{ itemId: '9007199254740993', actualPackages: 2 }] + const submit = () => confirmCargoDiscard('CD-1', 'P-1', lines, ['101'], ['BOX-1'], false, { idempotencyKey: 'discard-stable-key' }) + let calls = transport({ status: 'FREEZE_PENDING', ready: false }) + await assert.rejects(submit(), error => error.storageRentPreparation === true) + assert.equal(calls.length, 1) + const original = calls[0].data + assert.deepEqual(original, { mode: 'DISCARD', businessNo: 'CD-1', palletNo: 'P-1', lines, photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-stable-key' }) + calls = transport({ status: 'READY', ready: true }) + await submit() + assert.deepEqual(calls[0].data, original) + assert.equal(calls.length, 2) + assert.ok(calls[1].url.endsWith('/cargo-discards/CD-1/pallets/P-1/confirm')) + assert.deepEqual(calls[1].data, { lines, photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-stable-key' }) +})