Merge pull request 'fix(pda): 暂停未配套上线的仓租预检查,恢复库存作业' (#11) from fix/skip-unreleased-storage-rent into main

This commit was merged in pull request #11.
This commit is contained in:
2026-10-26 17:33:54 +08:00
7 changed files with 68 additions and 118 deletions
+1 -1
View File
@@ -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。 - 柜号签收入仓调用 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 代替授权和资源归属校验。 - 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。 - 排车和出库入口只调用专用 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`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
+7 -28
View File
@@ -3,7 +3,6 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches' const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks' const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards' 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()) const encoded = (value) => encodeURIComponent(String(value || '').trim())
@@ -98,30 +97,14 @@ function dispatchWrite(path, request) {
requireDispatchVersion(request?.recordVersion) requireDispatchVersion(request?.recordVersion)
return overseasRequest('POST', path, request) return overseasRequest('POST', path, request)
} }
async function prepareStorageRent(mode, businessNo, request, extra = {}) { // 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。
try { function requireStockCommandIdentity(request) {
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务') 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) { export async function pickDispatchItem(dispatchNo, dispatchItemId, request) {
requireDispatchVersion(request?.recordVersion) requireDispatchVersion(request?.recordVersion)
await prepareStorageRent('DISPATCH', dispatchNo, request, { requireStockCommandIdentity(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) return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
} }
export function completeDispatchLoading(dispatchNo, request) { export function completeDispatchLoading(dispatchNo, request) {
@@ -145,16 +128,12 @@ export function resolveOutboundTask(businessNo) {
} }
export async function pickOutboundTask(taskNo, request) { export async function pickOutboundTask(taskNo, request) {
await prepareStorageRent('OUTBOUND', taskNo, request, { requireStockCommandIdentity(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) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
} }
export async function pickOutboundPallet(taskNo, palletNo, 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) 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 = {}) { export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey } 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)) return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
} }
+1 -1
View File
@@ -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) 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 const sent = []; let reads = 0
storage() storage()
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } }) const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } })
+55
View File
@@ -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)
})
+1 -2
View File
@@ -116,7 +116,6 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
request(options) { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
let data = {} let data = {}
if (options.url.endsWith('/storage-rent/preparations')) data = { status: 'READY', ready: true }
if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = [] 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' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = []
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('/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 pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-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.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.endsWith('/dispatches')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick'))) assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
+3 -3
View File
@@ -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' }]) 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 submitted = []
const retry = deferred() const retry = deferred()
let attempt = 0 let attempt = 0
const { vm } = page({ 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 () => [] fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
}) })
vm.discardCurrent = { discardNo: 'CD-RETRY' } vm.discardCurrent = { discardNo: 'CD-RETRY' }
@@ -271,7 +271,7 @@ test('discard confirmation preserves inputs after preflight failure and starts a
await vm.confirmDiscard() await vm.confirmDiscard()
assert.equal(vm.discardBusy, false) assert.equal(vm.discardBusy, false)
assert.ok(vm.discardTask) assert.ok(vm.discardTask)
assert.equal(vm.error.message, '费用确认中') assert.equal(vm.error.message, '库存状态已变化')
vm.successMessage = '上次作业已完成' vm.successMessage = '上次作业已完成'
const retried = vm.confirmDiscard() const retried = vm.confirmDiscard()
assert.equal(vm.error, null) assert.equal(vm.error, null)
-83
View File
@@ -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' })
})