From 902d7bda86f0952be32dc93b4eb4b12ea78ae794 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 17:31:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(pda):=20=E6=9A=82=E5=81=9C=E6=9C=AA?= =?UTF-8?q?=E9=85=8D=E5=A5=97=E4=B8=8A=E7=BA=BF=E7=9A=84=E4=BB=93=E7=A7=9F?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E6=9F=A5=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/business-index.md | 2 +- src/api/fulfillment.js | 35 ++-------- tests/dispatch-workflow.test.mjs | 2 +- tests/fulfillment-stock-commands.test.mjs | 55 +++++++++++++++ tests/fulfillment-workflows.test.mjs | 3 +- tests/outbound-lifecycle.test.mjs | 6 +- tests/storage-rent-preflight.test.mjs | 83 ----------------------- 7 files changed, 68 insertions(+), 118 deletions(-) create mode 100644 tests/fulfillment-stock-commands.test.mjs delete mode 100644 tests/storage-rent-preflight.test.mjs diff --git a/docs/business-index.md b/docs/business-index.md index 21a6cf6..40b9e67 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -62,4 +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 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片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`,离线通过不代表实际冻结、扣款和出库联调已验收。 +- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。 diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js index bc56f24..78eadf9 100644 --- a/src/api/fulfillment.js +++ b/src/api/fulfillment.js @@ -3,7 +3,6 @@ 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()) @@ -98,30 +97,14 @@ function dispatchWrite(path, request) { requireDispatchVersion(request?.recordVersion) return overseasRequest('POST', path, 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 - } +// 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。 +function requireStockCommandIdentity(request) { + if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务') } 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 - }) + requireStockCommandIdentity(request) return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request) } export function completeDispatchLoading(dispatchNo, request) { @@ -145,16 +128,12 @@ export function resolveOutboundTask(businessNo) { } 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 - }) + requireStockCommandIdentity(request) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request) } export async function pickOutboundPallet(taskNo, palletNo, request) { - await prepareStorageRent('OUTBOUND_PALLET', taskNo, request, { palletNo }) + requireStockCommandIdentity(request) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request) } @@ -208,7 +187,7 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) { 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 }) + requireStockCommandIdentity(request) return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body)) } diff --git a/tests/dispatch-workflow.test.mjs b/tests/dispatch-workflow.test.mjs index f9b607d..acb6b73 100644 --- a/tests/dispatch-workflow.test.mjs +++ b/tests/dispatch-workflow.test.mjs @@ -267,7 +267,7 @@ test('thirty scanned pallets confirm once without weight volume or pallet entry' assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false) }) -for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }, { storageRentPreparation: true, code: 'A0409', message: '仓租费用尚未确认' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => { +for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => { const sent = []; let reads = 0 storage() const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } }) diff --git a/tests/fulfillment-stock-commands.test.mjs b/tests/fulfillment-stock-commands.test.mjs new file mode 100644 index 0000000..a9fb1b3 --- /dev/null +++ b/tests/fulfillment-stock-commands.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js' +import { session } from '../src/stores/session.js' + +// 模拟当前生产:仓租预检查接口不存在,既有库存命令仍正常受理。 +function transport(failure) { + const calls = [] + session.apiBase = '/api'; session.token = 'overseas-token' + globalThis.uni = { request(options) { + calls.push({ method: options.method, url: options.url, data: options.data }) + if (failure?.network) return options.fail({ errMsg: 'network down' }) + const error = options.url.includes('/storage-rent/') ? { code: 'A0404', message: 'Resource not found' } : failure + options.success({ statusCode: 200, data: error || { code: '200', data: { success: true } }, header: {} }) + } } + return calls +} + +const dispatch = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 21, cartonMark: null, productQuantity: 0, idempotencyKey: 'dispatch-key' } +const outbound = { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'outbound-key' } +const pallet = { idempotencyKey: 'pallet-key' } +const discard = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-key' } +const modes = [ + { name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) }, + { name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) }, + { name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) }, + { name: 'discard', path: '/cargo-discards/CD%2F1/pallets/P%2F1/confirm', body: discard, submit: () => confirmCargoDiscard('CD/1', 'P/1', discard.lines, discard.photoFileIds, discard.scannedCartonMarks, false, { idempotencyKey: discard.idempotencyKey }) } +] + +for (const mode of modes) { + test(`${mode.name} works without the unpublished rent endpoint and preserves its stock payload`, async () => { + const calls = transport() + await mode.submit() + assert.deepEqual(calls, [{ method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body }]) + }) + test(`${mode.name} propagates stock failures without fallback or automatic resubmission`, async () => { + for (const failure of [{ code: 'A0404', message: 'missing' }, { code: 'A0409', message: 'conflict' }, { code: 'S0503', message: 'unavailable' }, { network: true }]) { + const calls = transport(failure) + await assert.rejects(mode.submit(), error => failure.network ? error.network === true : error.code === failure.code) + assert.equal(calls.length, 1) + assert.equal(calls[0].url, '/api/pda/v1/last-leg' + mode.path) + assert.deepEqual(calls[0].data, mode.body) + } + }) +} + +test('invalid dispatch version and missing command keys fail before any request', async () => { + const calls = transport() + await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/) + await assert.rejects(pickDispatchItem('DSP-1', '11', { recordVersion: 1 }), /标识/) + await assert.rejects(pickOutboundTask('TASK-1', {}), /标识/) + await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/) + await assert.rejects(confirmCargoDiscard('CD-1', 'P-1', [], [], [], false, {}), /标识/) + assert.equal(calls.length, 0) +}) diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs index 314c185..26c9f00 100644 --- a/tests/fulfillment-workflows.test.mjs +++ b/tests/fulfillment-workflows.test.mjs @@ -116,7 +116,6 @@ 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: [] } @@ -136,7 +135,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, 16) + assert.equal(calls.length, 13) 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 d49438c..c7b2183 100644 --- a/tests/outbound-lifecycle.test.mjs +++ b/tests/outbound-lifecycle.test.mjs @@ -257,12 +257,12 @@ test('partial bound pallet confirmation sends only selected marks and preserves assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }]) }) -test('discard confirmation preserves inputs after preflight failure and starts a new checked request', async () => { +test('discard confirmation preserves inputs after stock command failure and starts a new explicit request', async () => { 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 }, + confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('库存状态已变化'); await retry.promise }, fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => [] }) vm.discardCurrent = { discardNo: 'CD-RETRY' } @@ -271,7 +271,7 @@ test('discard confirmation preserves inputs after preflight failure and starts a await vm.confirmDiscard() assert.equal(vm.discardBusy, false) assert.ok(vm.discardTask) - assert.equal(vm.error.message, '费用确认中') + assert.equal(vm.error.message, '库存状态已变化') vm.successMessage = '上次作业已完成' const retried = vm.confirmDiscard() assert.equal(vm.error, null) diff --git a/tests/storage-rent-preflight.test.mjs b/tests/storage-rent-preflight.test.mjs deleted file mode 100644 index cfb3d65..0000000 --- a/tests/storage-rent-preflight.test.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -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) - } -}) - -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' }) -}) -- 2.54.0