fix: reuse overseas receiving resources in acceptance

This commit is contained in:
2026-09-11 00:48:21 +08:00
parent 9abb0004dd
commit 7dff9b57ed
8 changed files with 107 additions and 85 deletions
@@ -238,7 +238,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
String deliveryMethod = trim(request.deliveryMethod());
if (requiresOutbound && !DELIVERY_METHODS.contains(deliveryMethod)) {
if (requiresOutbound && (!StringUtils.hasText(deliveryMethod) || !DELIVERY_METHODS.contains(deliveryMethod))) {
throw badRequest("Delivery method must be TRUCK, PICKUP or EXPRESS when carton labels are changed");
}
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
@@ -90,4 +90,12 @@ class RelabelContractTest {
openApi.indexOf(" RelabelItem:"));
assertFalse(relabelCreate.contains("customerId"));
}
@Test
void missingDeliveryMethodIsValidatedWithoutCallingNullRejectingSetContains() throws Exception {
String service = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java"),
StandardCharsets.UTF_8);
assertTrue(service.contains("requiresOutbound && (!StringUtils.hasText(deliveryMethod)"
+ " || !DELIVERY_METHODS.contains(deliveryMethod))"));
}
}
+2 -2
View File
@@ -91,7 +91,7 @@ export async function run(context) {
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
@@ -106,7 +106,7 @@ export async function run(context) {
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
@@ -5,7 +5,7 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { auxiliaryPalletFbaWarehouseCode, run } from './phase4-overseas-pda-operations.mjs'
import { run } from './phase4-overseas-pda-operations.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
@@ -54,6 +54,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null',
'newLabel: item.fbaCartonMark || item.newSkuCode',
"operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:',
"deliveryMethod: 'TRUCK'", 'printShippingLabel: false',
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) {
assert.ok(source.includes(marker), marker)
}
@@ -65,16 +66,16 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
assert.match(source, /traceId=/)
})
test('auxiliary pallets use the authoritative inbound FBA code and never the business destination fallback', () => {
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ' ABE3 '), 'ABE3')
assert.equal(auxiliaryPalletFbaWarehouseCode('OVERSEAS', 'ABE3'), null)
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ''), null)
test('PDA operations reuse received plan pallets and the public location without creating resources', () => {
assert.match(receivingSource,
/destinationType: inbound\.destinationType,[\s\S]*fbaWarehouseCode: inbound\.destinationType === 'FBA' \? inbound\.fbaWarehouseCode : null/)
assert.match(source, /fbaWarehouseCode,\s*\n\s*remark:/)
assert.match(source, /createLocation\(context, fixture\.overseasWarehouseId, `\$\{suffix\}-PALLET`\)/)
assert.match(source, /locationId: location\.locationId/)
assert.doesNotMatch(source, /createEmptyPallet\([^\n]*main\.destinationWarehouseCode/)
/auxiliaryPallets: generatedContainerPallets\.filter/)
assert.match(source, /resolvePublicLocation/)
assert.match(source, /resolveReusableAuxiliaryPallet/)
assert.match(source, /locationType === 'VIRTUAL'/)
assert.match(source, /P3托盘已在公共库位不重复移库/)
assert.doesNotMatch(source, /warehouse-admin[^'`]*\/locations`,\s*\{/)
assert.doesNotMatch(source, /warehouse-admin[^'`]*\/pallets`,\s*\{/)
assert.doesNotMatch(source, /createLocation|createEmptyPallet/)
})
test('fixture failure still disables both picker accounts and removes all transient tokens', async () => {
@@ -90,47 +90,29 @@ async function resolvePallet(context, token, palletCode, name = '扫码解析托
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
}
async function createLocation(context, warehouseId, suffix) {
const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64)
const response = await context.requestOverseas('POST',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000,
status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`,
})
const locationId = id(response?.body?.data)
context.addStep(MODULE, `创建${suffix}目标库位`, response,
success(response) && /^\d+$/.test(locationId) && locationId !== '0',
`locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS)
return success(response) ? { locationId, locationCode } : null
async function resolvePublicLocation(context, fixture) {
const response = await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
const location = records(response?.body?.data).find((item) => item?.locationType === 'VIRTUAL'
&& item?.status === 'ACTIVE')
context.addStep(MODULE, '复用当前海外仓公共库位', response,
success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode),
`locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS)
return success(response) && location
? { locationId: id(location.id), locationCode: location.locationCode } : null
}
export function auxiliaryPalletFbaWarehouseCode(destinationType, fbaWarehouseCode) {
if (destinationType === 'OVERSEAS') return null
if (destinationType === 'FBA' && nonBlank(fbaWarehouseCode)) return fbaWarehouseCode.trim()
return null
}
async function createEmptyPallet(context, token, fixture, suffix) {
const fbaWarehouseCode = auxiliaryPalletFbaWarehouseCode(
fixture.destinationType, fixture.fbaWarehouseCode)
if (fixture.destinationType === 'FBA' && !fbaWarehouseCode) {
blocker(context, `${suffix}辅助空托盘`,
`当前FBA入仓码 ${fixture.inboundCode || 'missing'} 缺少权威fbaWarehouseCode`)
return null
async function resolveReusableAuxiliaryPallet(context, token, fixture) {
for (const candidate of fixture.auxiliaryPallets || []) {
if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue
const resolved = await resolvePallet(context, token, candidate.palletCode, '解析入仓计划已有空托盘')
if (resolved.data && Array.isArray(resolved.data.inventory) && resolved.data.inventory.length === 0) {
context.assert(MODULE, '辅助托盘只复用本轮入仓计划已有空托盘', resolved.response, true,
`palletCode=${resolved.data.palletCode}; ${trace(resolved.response)}`, OPTIONS)
return resolved.data
}
}
const location = await createLocation(context, fixture.overseasWarehouseId, `${suffix}-PALLET`)
if (!location) return null
const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120)
const response = await context.requestOverseas('POST',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, {
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE',
locationId: location.locationId,
fbaWarehouseCode,
remark: `${context.marker}-pda-operations-${suffix}`,
})
context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS)
if (!success(response)) return null
return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data
return null
}
async function verifyStocktake(context, token, fixture) {
@@ -201,7 +183,7 @@ async function verifyStocktake(context, token, fixture) {
}
async function verifyInventoryMove(context, token, fixture) {
const target = await createLocation(context, fixture.overseasWarehouseId, 'MOVE')
const target = await resolvePublicLocation(context, fixture)
if (!target) return false
let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data
if (!pallet || !nonBlank(pallet.locationCode)) {
@@ -209,6 +191,14 @@ async function verifyInventoryMove(context, token, fixture) {
return false
}
const originalLocationCode = pallet.locationCode
if (originalLocationCode === target.locationCode) {
const availablePackages = (pallet.inventory || []).reduce((sum, item) =>
sum + Number(item?.availablePackages || 0), 0)
context.assert(MODULE, 'P3托盘已在公共库位不重复移库', null,
availablePackages > 0,
`palletCode=${pallet.palletCode}, locationCode=${target.locationCode}, availablePackages=${availablePackages}`, OPTIONS)
return availablePackages > 0
}
const path = '/api/pda/v1/last-leg/inventory-moves'
const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode,
expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'),
@@ -258,23 +248,21 @@ async function verifyPalletOperations(context, token, fixture) {
'主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱')
return false
}
const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP')
const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT')
const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND')
if (!swapTarget || !splitTarget || !unbindTarget) {
blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘')
let auxiliary = await resolveReusableAuxiliaryPallet(context, token, fixture)
if (!auxiliary) {
blocker(context, 'P4辅助实物托盘', '本轮入仓计划没有可复用的已有空托盘')
return false
}
const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.palletVersion) },
target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
reason: `${context.marker}-swap` }
const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP')
await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`,
{ ...swapBody, reason: `${swapBody.reason}-DIFFERENT` })
let occupiedSwap = operationPallet(swapped.data, swapTarget.palletCode)
|| (await resolvePallet(context, token, swapTarget.palletCode, 'P4换托后解析目标托盘')).data
let occupiedSwap = operationPallet(swapped.data, auxiliary.palletCode)
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4换托后解析目标托盘')).data
let emptyMain = operationPallet(swapped.data, main.palletCode)
|| (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data
await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, {
@@ -288,19 +276,20 @@ async function verifyPalletOperations(context, token, fixture) {
await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP')
main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data
auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托前刷新已有空托盘')).data
const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1)
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
const onePackage = 1
const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
targets: [{ target: { palletCode: splitTarget.palletCode, expectedVersion: Number(splitTarget.palletVersion) },
targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
lines: [{ positionId: id(sourceLine.positionId), packages: onePackage,
expectedPositionVersion: Number(sourceLine.positionVersion) }] }],
reason: `${context.marker}-disassemble` }
const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘',
`${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE')
const splitOccupied = operationPallet(disassembled.data, splitTarget.palletCode)
|| (await resolvePallet(context, token, splitTarget.palletCode, 'P4拆托后解析辅助托盘')).data
const splitOccupied = operationPallet(disassembled.data, auxiliary.palletCode)
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托后解析辅助托盘')).data
const mainAfterSplit = operationPallet(disassembled.data, main.palletCode)
|| (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data
const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0)
@@ -313,18 +302,19 @@ async function verifyPalletOperations(context, token, fixture) {
await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE')
main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data
auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4解绑前刷新已有空托盘')).data
const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1)
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'),
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
targets: [{ target: { palletCode: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.palletVersion) },
targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
lines: [{ positionId: id(unbindSource.positionId), packages: 1,
expectedPositionVersion: Number(unbindSource.positionVersion) }] }],
reason: `${context.marker}-unbind-prepare` }
const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本',
`${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE')
const boundAuxiliary = operationPallet(prepared.data, unbindTarget.palletCode)
|| (await resolvePallet(context, token, unbindTarget.palletCode, 'P4解析解绑样本托盘')).data
const boundAuxiliary = operationPallet(prepared.data, auxiliary.palletCode)
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4解析解绑样本托盘')).data
const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'),
source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) },
reason: `${context.marker}-unbind` }
@@ -393,6 +383,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
customerOrderNo: String(customerOrderNo).slice(0, 128), operationWarehouseId: id(fixture.overseasWarehouseId),
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker,
deliveryMethod: 'TRUCK', printShippingLabel: false,
idempotencyKey: key(context, 'RELABEL-CREATE'),
items: sourceLines.map(({ source, position, packageCount }, index) => {
const newSkuCode = `${source.skuCode}-NEW-${markerSuffix}-${index + 1}`.slice(0, 128)
@@ -204,31 +204,38 @@ async function resolveGeneratedPallet(context, inbound) {
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存',
'/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response,
success(inventory.response) && inventory.data?.queryCode === inboundCode
&& Array.isArray(inventory.data?.items) && inventory.data.items.length > 0
&& inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId
&& id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0),
&& inventoryItems.length > 0
&& inventoryItems.every((item) => longId(id(item?.palletId))
&& longId(id(item?.locationId)) && nonBlank(item?.palletCode)
&& nonBlank(item?.locationCode) && Number(item?.availablePackages) > 0),
`items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS)
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
context.assert(MODULE, `${destinationType}实收形成服务端SKU库存账`, inventory.response,
success(inventory.response) && inventoryItems.length > 0
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
const occupied = [...inventoryItems].sort((left, right) =>
Number(right?.availablePackages || 0) - Number(left?.availablePackages || 0))[0]
const actualPalletCode = occupied?.palletCode || pallet.palletCode
const actualPalletId = id(occupied?.palletId || pallet.palletId)
const actualLocationCode = occupied?.locationCode || pallet.locationCode
const actualLocationId = id(occupied?.locationId || pallet.locationId)
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
'/api/pda/v1/last-leg/locations/resolve', { code: actualLocationCode })
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
success(location.response) && id(location.data?.locationId) === pallet.locationId
&& location.data?.locationCode === pallet.locationCode
success(location.response) && id(location.data?.locationId) === actualLocationId
&& location.data?.locationCode === actualLocationCode
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
'/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode })
'/api/pda/v1/last-leg/pallets/resolve', { code: actualPalletCode })
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === pallet.palletId
&& id(resolvedPallet.data?.locationId) === pallet.locationId
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === actualPalletId
&& id(resolvedPallet.data?.locationId) === actualLocationId
&& Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0
&& Number(resolvedPallet.data?.occupiedPackages) > 0,
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
@@ -242,7 +249,8 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
&& Number(history.data?.page) === 1 && Number(history.data?.size) === 50,
`records=${historyRows.length}; ${trace(history.response)}`, OPTIONS)
return success(inventory.response) && success(location.response) && success(resolvedPallet.response)
? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] }
? { inboundCode, pallet: resolvedPallet.data, location: location.data,
inventory: inventoryItems.filter((item) => id(item?.palletId) === actualPalletId) }
: null
}
@@ -804,24 +812,36 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
const physicalPallets = [...new Map([...pallets.values()]
.map((item) => [id(item.palletId), item])).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
palletIds: physicalPallets.map((item) => item.palletId),
})
const generatedContainerPallets = current.data.inbounds.flatMap((item) =>
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[item.inboundCode] || [])
const completedFixtures = []
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
pallets.get(inbound.inboundCode), inbound.destinationType)
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
context.context.phase4OverseasPdaOperations.completedFixtures.push({
const fixture = {
containerNo, order, destinationType: inbound.destinationType,
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
auxiliaryPallets: generatedContainerPallets.filter((candidate) =>
id(candidate?.palletId) !== id(warehouseFacts.pallet?.palletId)),
...warehouseFacts,
})
}
context.context.phase4OverseasPdaOperations.completedFixtures.push(fixture)
completedFixtures.push(fixture)
}
}
const receivedPallets = [...new Map(completedFixtures.map((fixture) => {
const palletId = id(fixture.pallet?.palletId)
const source = physicalPallets.find((candidate) => id(candidate?.palletId) === palletId)
return [palletId, { palletId, unitPrice: Number(source?.unitPrice || 0) }]
}).filter(([palletId]) => longId(palletId))).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: receivedPallets.reduce((sum, item) => sum + item.unitPrice, 0),
palletIds: receivedPallets.map((item) => item.palletId),
})
}
}
@@ -39,6 +39,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
'destinationFbaWarehouseCode: source.destinationFbaWarehouseCode',
'cartonMark:',
'fbaCartonMark:',
"deliveryMethod: 'TRUCK'",
]) assert.ok(source.includes(token), token)
assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/)
assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/)
@@ -177,6 +177,7 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
customerOrderNo: `${context.marker}-${suffix}`.slice(0, 128), operationWarehouseId: id(warehouseId),
labelDeadline: new Date(Date.now() + 86_400_000).toISOString().slice(0, 19),
contactName: '全量验收联系人', contactPhone: '+1-213-555-0199', chargeAmount,
deliveryMethod: 'TRUCK', printShippingLabel: false,
remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255),
items: [{ sourceInventoryBalanceId: id(source.inventoryBalanceId),
sourceInventoryPositionId: id(source.position.positionId), sourceInboundOrderId: id(source.inboundOrderId),