优化
This commit is contained in:
@@ -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`,离线通过不代表实际冻结、扣款和出库联调已验收。
|
||||
|
||||
+37
-5
@@ -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`
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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, /已完成/)
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
Reference in New Issue
Block a user