diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 89d747850..5d484b6d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -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)) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index c1611680a..08c501244 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -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))")); + } } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 201863e74..b9a02207c 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index c977ff5fe..254a5b2f6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -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 () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 4db39f3ba..d0799d931 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e9f6d1913..b7cea1178 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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), + }) } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index 052040aa6..191efa229 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -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\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index f8dc1e461..9e7ccde98 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -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),