From 436393d4ea42a380ae4cf85be10fcefd2a62ee8c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 2 Nov 2026 17:16:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/business-index.md | 2 +- src/api/fulfillment.js | 19 +++++++-- tests/dispatch-rent-preparation.test.mjs | 48 +++++++++++++++++++++++ tests/fulfillment-stock-commands.test.mjs | 18 +++++---- tests/fulfillment-workflows.test.mjs | 3 +- 5 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 tests/dispatch-rent-preparation.test.mjs diff --git a/docs/business-index.md b/docs/business-index.md index 24ffe99..3ac5f25 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -62,6 +62,6 @@ - 柜号签收入仓调用 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 不直接创建派送单,平台与客户的读取范围遵循现行契约。 -- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。 +- 排车拣货在提交库存扣减前调用 `/storage-rent/preparations` 完成仓租预校验和资金预占,预占回执未就绪或失败时不提交拣货;成功后仍只提交原排车库存命令,保留货源参数、箱数、版本和 `idempotencyKey`。预校验失败会释放未消费预占并保留错误诊断,不自动重放拣货。普通出库、整托出库和废弃确认沿各自现有流程;定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖排车仓租预校验、库存命令参数和失败传播。 - 废弃出库展示客户废弃备注。无关联箱唛库存扫描本单托盘后扫描现场箱唛,自动去重计数;多SKU先选择本次扫描货物,不手填实际数。每条 lines.scannedCartonMarks 与 actualPackages 一并提交,失败留箱唛/照片供刷新重试;后台保存实际扫描清单并回显废弃详情。有箱唛关联任务仍核对本单所选箱唛。 diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js index 2da6e36..6a4bde6 100644 --- a/src/api/fulfillment.js +++ b/src/api/fulfillment.js @@ -97,7 +97,7 @@ function dispatchWrite(path, request) { requireDispatchVersion(request?.recordVersion) return overseasRequest('POST', path, request) } -// 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。 +// 库存命令与仓租预校验共用本次请求的防重标识。 function requireStockCommandIdentity(request) { if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务') } @@ -105,8 +105,19 @@ function requireStockCommandIdentity(request) { export async function pickDispatchItem(dispatchNo, dispatchItemId, request) { requireDispatchVersion(request?.recordVersion) requireStockCommandIdentity(request) + await prepareStockRent({ + mode: 'DISPATCH', + businessNo: dispatchNo, + itemId: dispatchItemId, + positionId: request.positionId, + packages: request.packages, + cartonMark: request.cartonMark || null, + idempotencyKey: request.idempotencyKey, + productQuantity: request.productQuantity || 0 + }) return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request) } + export function completeDispatchLoading(dispatchNo, request) { return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request) } @@ -192,12 +203,12 @@ export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileI const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey } requireStockCommandIdentity(request) // 出库前按同一份货物、照片和幂等键校验仓租,资金就绪后才提交库存扣减。 - await prepareDiscardRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body }) + await prepareStockRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body }) return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body)) } /** 本次点击内只查询资金回执,不缓存或自动重放失败的出库命令。 */ -async function prepareDiscardRent(body) { +async function prepareStockRent(body) { const base = '/pda/v1/last-leg/storage-rent/preparations' let preparationId try { @@ -215,7 +226,7 @@ async function prepareDiscardRent(body) { // 尚未发送出库命令时释放已知的未使用预占,释放失败保留原错误诊断。 if (preparationId) { try { await overseasRequest('DELETE', `${base}/${encoded(preparationId)}`) } - catch (releaseError) { console.error('[Overseas PDA discard] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) } + catch (releaseError) { console.error('[Overseas PDA stock] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) } } throw error } diff --git a/tests/dispatch-rent-preparation.test.mjs b/tests/dispatch-rent-preparation.test.mjs new file mode 100644 index 0000000..50d1971 --- /dev/null +++ b/tests/dispatch-rent-preparation.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { pickDispatchItem } from '../src/api/fulfillment.js' +import { session } from '../src/stores/session.js' + +const path = '/api/pda/v1/last-leg/storage-rent/preparations' +const pick = '/api/pda/v1/last-leg/dispatches/PC1/items/9007199254740995/pick' +const body = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 2, productQuantity: 0, cartonMark: null, idempotencyKey: 'dispatch-rent-1' } +const ready = { data: { preparationId: 'rent1', ready: true, status: 'READY' } } +const pending = { data: { preparationId: 'rent1', ready: false, status: 'FREEZE_PENDING' } } +const submit = () => pickDispatchItem('PC1', '9007199254740995', body) +function transport(responses) { + const calls = []; session.apiBase = '/api'; session.token = 'overseas-token' + globalThis.uni = { request(options) { + calls.push({ method: options.method, url: options.url, data: options.data }) + const response = responses.shift(); assert.ok(response, 'unexpected request') + if (response.network) return options.fail({ errMsg: 'network down' }) + options.success({ statusCode: 200, data: { code: '200', ...response }, header: {} }) + } } + return calls +} +test('排车仓租与实际拣货绑定同一来源、数量和幂等键,保持长ID精度', async () => { + const calls = transport([ready, { data: { success: true } }]); await submit() + assert.deepEqual(calls, [ + { method: 'POST', url: path, data: { mode: 'DISPATCH', businessNo: 'PC1', itemId: '9007199254740995', positionId: body.positionId, packages: 2, cartonMark: null, idempotencyKey: body.idempotencyKey, productQuantity: 0 } }, + { method: 'POST', url: pick, data: body } + ]) +}) +test('免租或不计租仍按后端就绪结果继续,非布尔就绪禁止拣货', async () => { + const calls = transport([{ data: { ready: true, status: 'NOT_REQUIRED' } }, { data: {} }]); await submit(); assert.equal(calls.at(-1).url, pick) + const blocked = transport([{ data: { ready: 'true' } }]); await assert.rejects(submit(), /预校验/); assert.equal(blocked.length, 1) +}) +test('预占待回执只读查询,就绪后仅发一次拣货', async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 }) + const calls = transport([pending, ready, { data: {} }]); await submit() + assert.deepEqual(calls.map(c => [c.method, c.url]), [['POST', path], ['GET', path + '/rent1'], ['POST', pick]]) +}) +test('资金失败释放预占;预校验404或断网不绕过,不发拣货', async () => { + const calls = transport([{ data: { preparationId: 'rent1', ready: false, status: 'FAILED', message: '余额不足' } }, { data: {} }]) + await assert.rejects(submit(), /余额不足/); assert.deepEqual(calls.map(c => c.method), ['POST', 'DELETE']) + for (const failure of [{ code: 'A0404', message: 'missing', requestId: 'req1' }, { network: true }]) { + const failed = transport([failure]); await assert.rejects(submit(), e => failure.network ? e.network : e.code === failure.code && e.requestId === 'req1'); assert.equal(failed.length, 1) + } +}) +test('拣货请求发出后结果不明,不重发也不释放可能已消费的预占', async () => { + const calls = transport([ready, { network: true }]); await assert.rejects(submit(), e => e.network) + assert.deepEqual(calls.map(c => c.method), ['POST', 'POST']) +}) diff --git a/tests/fulfillment-stock-commands.test.mjs b/tests/fulfillment-stock-commands.test.mjs index a871870..aa9344c 100644 --- a/tests/fulfillment-stock-commands.test.mjs +++ b/tests/fulfillment-stock-commands.test.mjs @@ -3,15 +3,15 @@ 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: {} }) + const rent = options.url.includes('/storage-rent/') + options.success({ statusCode: 200, data: rent ? { code: '200', data: { preparationId: 'rent-1', ready: true, status: 'READY' } } : failure || { code: '200', data: { success: true } }, header: {} }) } } return calls } @@ -27,18 +27,20 @@ const modes = [ ] for (const mode of modes) { - test(`${mode.name} works without the unpublished rent endpoint and preserves its stock payload`, async () => { + test(`${mode.name} 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 }]) + const stock = calls.find(call => call.url.includes(mode.path)) + assert.deepEqual(stock, { method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body }) + if (mode.name === 'dispatch') assert.equal(calls.filter(call => call.url.includes('/storage-rent/')).length, 1) }) 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) + const stock = calls.find(call => call.url.includes(mode.path)) + if (failure.network && mode.name === 'dispatch') assert.equal(stock, undefined) + else assert.deepEqual(stock, { method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body }) } }) } diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs index 26c9f00..2f04d92 100644 --- a/tests/fulfillment-workflows.test.mjs +++ b/tests/fulfillment-workflows.test.mjs @@ -121,6 +121,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] } else if (options.method === 'GET' && options.url.includes('/outbound-tasks/resolve/')) data = { workflow: 'DISPATCH', dispatchNo: 'DSP-1', outboundNo: 'OUT-1' } else if (options.method === 'GET' && options.url.includes('/outbound-tasks/')) data = { task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1' }, items: [] } + else if (options.method === 'POST' && options.url.includes('/storage-rent/preparations')) data = { preparationId: 'rent-1', ready: true, status: 'READY' } options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } } @@ -135,7 +136,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, 14) 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')))