fix: reuse overseas receiving resources in acceptance
This commit is contained in:
+1
-1
@@ -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))"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user