From a2ea0ba9e6ae5b71f0cc506c0cb52c9383d44eca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 12:40:40 +0800 Subject: [PATCH] fix: align HTTP acceptance fixtures and pallet permissions --- deploy/sql/海外区域统一增量-MySQL8.sql | 5 ++ ...atformDropshipPalletizationController.java | 4 +- .../openapi/dropship-palletization-v1.yaml | 4 +- .../DropshipPalletizationSqlContractTest.java | 15 ++++ .../src/main/resources/db/lastleg.sql | 4 + .../service/ReturnCartonQueryTest.java | 22 +++++ scripts/admin-test-env-modules/README.md | 5 ++ ...und-measurement-box-code-contract.test.mjs | 6 +- .../inbound-measurement.mjs | 22 +++-- ...ase3-container-documents-contract.test.mjs | 10 ++- .../phase3-container-documents.mjs | 33 ++++++- .../phase4-overseas-pda-contract.test.mjs | 9 +- ...-overseas-pda-operations-contract.test.mjs | 21 ++++- .../phase4-overseas-pda-operations.mjs | 87 +++++++++++++------ .../phase4-overseas-pda.mjs | 8 +- 15 files changed, 205 insertions(+), 50 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c10d5d899..483d4b058 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1799,6 +1799,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_relabel_item ( + return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID', + source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照', + new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -2564,6 +2567,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no')); PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt; CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( + new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛', id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; @@ -4589,6 +4593,7 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', source_return_no VARCHAR(2000) NULL COMMENT '来源退货单号', fba_carton_mark VARCHAR(255) NULL COMMENT 'FBA箱唛', + new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index 59b3c9e17..a9569169a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -142,7 +142,7 @@ public class PlatformDropshipPalletizationController { } @GetMapping("/inbound-orders/{inboundId}/pallets") - @PreAuthorize("hasAuthority('firstleg:inbound:list')") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:list')") public ApiResponse> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long inboundId, @RequestParam @Positive long accountId) { @@ -151,7 +151,7 @@ public class PlatformDropshipPalletizationController { } @GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print") - @PreAuthorize("hasAuthority('firstleg:inbound:list')") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:list')") public ResponseEntity printPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long inboundId, @PathVariable @Positive long palletId, diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index afa76cfd2..89a97b6e6 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -87,12 +87,12 @@ paths: /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: get: summary: Admin 查询入仓单权威托盘结果 - x-required-authority: firstleg:inbound:list + x-required-authority: platform:firstleg:inbound:list responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: get: summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单 - x-required-authority: firstleg:inbound:list + x-required-authority: platform:firstleg:inbound:list responses: {'200': {description: PDF}} components: parameters: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java index b549689c2..fe1bf0f99 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -12,6 +12,21 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class DropshipPalletizationSqlContractTest { + @Test + void inboundPalletReadAndPrintUseTheRegisteredPlatformInboundPermission() throws Exception { + var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class; + for (var method : controller.getDeclaredMethods()) { + if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet")) { + assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:inbound:list')"); + } + } + String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(api).contains("x-required-authority: platform:firstleg:inbound:list") + .doesNotContain("x-required-authority: firstleg:inbound:list"); + } + @Test void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception { String sql = DropshipPalletizationMapper.class diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 4f5118e50..c9e915a58 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1706,6 +1706,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_relabel_item ( + return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID', + source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照', + new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -2471,6 +2474,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no')); PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt; CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( + new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛', id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java index 491b1b4c7..673d0c705 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java @@ -12,6 +12,28 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class ReturnCartonQueryTest { + @Test + void baseMapperReplayColumnExistsInEveryReturnCartonBaseline() throws Exception { + var table = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( + new org.apache.ibatis.builder.MapperBuilderAssistant( + new com.baomidou.mybatisplus.core.MybatisConfiguration(), "return-carton-contract"), + com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton.class); + assertThat(table.getAllSqlSelect()).contains("new_carton_mark"); + for (var path : java.util.List.of(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"), + java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) { + String sql = java.nio.file.Files.readString(path); + var matcher = java.util.regex.Pattern.compile( + "CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE") + .matcher(sql); + int definitions = 0; + while (matcher.find()) { + definitions++; + assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)"); + } + assertThat(definitions).isPositive(); + } + } + @Test void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception { var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT"); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c885a80c7..58cbc394a 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -37,6 +37,11 @@ export async function run(context) { ## 可用上下文 +- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。 +- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 +- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 +- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。 + 货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。 一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index acf6fa746..8ed2a6bf6 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -123,7 +123,7 @@ test('confirmed package count reconciles an uncertain transport result before re }) async function exerciseSystemPalletScenario(fault = '') { - const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED', + const task = { taskId: '81', inboundId: '82', warehouseId: '83', tenantId: '85', generatedPalletCount: 1, palletSetupReady: true, taskStatus: 'CLAIMED', totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] } const seenCounts = new Map() const seenCreates = new Set() @@ -138,7 +138,9 @@ async function exerciseSystemPalletScenario(fault = '') { assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) }, async request(method, path, body) { if (path.includes('/pending?')) return response([task]) - if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task) + if (path.endsWith('/claim')) return response({ ...task, pallets: [] }) + if (path === '/api/platform/v1/first-leg/pda-dropship/inbound-orders/82/pallets?accountId=85') return response(task.pallets) + if (method === 'GET' && path.includes('/tasks/81?')) return response(task) if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] }) if (path.endsWith('/cartons/count')) { if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 1b3122d4f..16263019b 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -112,12 +112,20 @@ export async function completeDropshipPalletization(context, { inboundId, inboun const totalCartons = Number(measurement?.actualPackageCount) const expectedPalletCount = Number(measurement?.expectedPalletCount) const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}` - const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : [] - context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse, - generatedPallets.length === expectedPalletCount && generatedPallets.every((item) => - item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')), + if (!claimed?.tenantId || claimed.taskStatus !== 'CLAIMED') return null + // Task.pallets contains pallets with actual bound cartons, not unused pre-generated pallets. + const palletResponse = await context.request('GET', + `/api/platform/v1/first-leg/pda-dropship/inbound-orders/${inboundId}/pallets?accountId=${asId(claimed.tenantId)}`) + const palletData = context.apiSuccess(MODULE, '读取本轮入仓单预生成托盘', palletResponse, '', OPTIONS) + const generatedPallets = Array.isArray(palletData) ? palletData : [] + const palletSetupValid = Number(claimed.generatedPalletCount) === expectedPalletCount + && claimed.palletSetupReady === true && generatedPallets.length === expectedPalletCount + && generatedPallets.every((item) => + item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')) + context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', palletResponse, + palletSetupValid, `expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS) - if (!generatedPallets.length) return null + if (!palletSetupValid) return null let countedCartons = 0 const sealedCartonMarks = [] @@ -189,13 +197,13 @@ export async function completeDropshipPalletization(context, { inboundId, inboun context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse, Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1 && pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo - && bound?.pallets?.length === expectedPalletCount, + && bound?.pallets?.length === 1, `unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS) const replayBindResponse = await context.request('POST', `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload) const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS) context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse, - replayBind?.pallets?.length === expectedPalletCount + replayBind?.pallets?.length === bound?.pallets?.length && Number(replayBind?.boundCartons) === Number(bound?.boundCartons) && Number(replayBind?.unboundCartons) === 0, '', OPTIONS) } else { diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index 3ec849814..32b0234a4 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -2,7 +2,15 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { DOCUMENT_TYPES } from './phase3-container-documents.mjs' +import { DOCUMENT_TYPES, applicableDocumentTypes } from './phase3-container-documents.mjs' + +test('rail excludes DO but truck still requires it; missing channel fails closed', () => { + for (const code of ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']) { + assert.deepEqual(applicableDocumentTypes(code), ['DESTINATION_CLEARANCE']) + } + assert.deepEqual(applicableDocumentTypes('EXX_TRUCK'), DOCUMENT_TYPES) + assert.throws(() => applicableDocumentTypes(null), /权威渠道/) +}) import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/phase3-container-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs index 93c2ba943..00bc03c9b 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -6,6 +6,13 @@ export const DOCUMENT_TYPES = Object.freeze([ 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', ]) +export function applicableDocumentTypes(channelCode) { + if (!String(channelCode || '').trim()) throw new Error('货柜缺少权威渠道,不能判断DO适用性') + const seaRail = ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT'] + .includes(String(channelCode).trim().toUpperCase()) + return seaRail ? ['DESTINATION_CLEARANCE'] : [...DOCUMENT_TYPES] +} + function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function success(response) { return response?.http === 200 && codeOf(response) === '200' } @@ -115,6 +122,13 @@ export async function run(context) { return } + const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${containerId}`) + const detail = context.apiSuccess(MODULE, '读取本轮货柜权威渠道', detailResponse, '', OPTIONS) + if (id(detail?.container?.id) !== containerId || !detail?.container?.channelCode) { + context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜权威渠道', OPTIONS) + return + } + const expectedTypes = applicableDocumentTypes(detail.container.channelCode) const specs = [ { type: 'DESTINATION_CLEARANCE', @@ -122,7 +136,7 @@ export async function run(context) { readPath: `${CONTAINERS}/${containerId}/clearance`, completePath: `${CONTAINERS}/${containerId}/clearance/complete`, }, - ...['DELIVERY_ORDER'].map((type) => ({ + ...expectedTypes.filter((type) => type === 'DELIVERY_ORDER').map((type) => ({ type, uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, readPath: `${CONTAINERS}/${containerId}/documents/${type}`, @@ -131,17 +145,28 @@ export async function run(context) { const documents = [] for (const spec of specs) documents.push(await uploadAndVerify(context, spec)) + let applicabilityPassed = true + if (!expectedTypes.includes('DELIVERY_ORDER')) { + const rejected = await upload(context, `${CONTAINERS}/${containerId}/documents/DELIVERY_ORDER/upload`, + 'DELIVERY_ORDER', 0, `${context.marker} 海铁DO负向验收`, key(context, 'rail-do-rejected')) + applicabilityPassed = conflict(rejected) && traced(rejected) + && rejected.body?.message === '海铁渠道不适用DO资料' + context.addStep(MODULE, '海铁DO上传按渠道规则拒绝', rejected, applicabilityPassed, + `code=${codeOf(rejected)}, requestId=${rejected.requestId}, traceId=${rejected.traceId}`, OPTIONS) + } const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`) const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS) const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType)) + const typesValid = expectedTypes.every((type) => storedTypes.has(type)) + && (expectedTypes.includes('DELIVERY_ORDER') || !storedTypes.has('DELIVERY_ORDER')) context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse, id(overview?.container?.id) === containerId && documents.every(Boolean) - && DOCUMENT_TYPES.every((type) => storedTypes.has(type)), + && typesValid && applicabilityPassed, `containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS) - if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) { + if (id(overview?.container?.id) === containerId && documents.every(Boolean) && typesValid && applicabilityPassed) { context.createdResources.phase3ContainerDocuments = { marker: context.marker, containerBookingId: containerId, loadingPlanId: planId, - warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES], + warehouseId, finalStatus: 'COMPLETED', documentTypes: expectedTypes, documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])), } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 556e3d9d5..fee612bc2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -4,7 +4,14 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs' +import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs' + +test('one physical container is received once even when matrix roles overlap', () => { + const fba = { containerNo: 'TEST-C1', fulfillmentType: 'STANDARD' } + const overseas = { containerNo: 'TEST-C2' } + assert.deepEqual([null, fba, { ...fba }, overseas].filter(uniqueContainerProducer), [fba, overseas]) + assert.doesNotMatch(source, /dropship \|\| matrix/) +}) const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') 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 3950e3e50..6ddf32790 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,26 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { run } from './phase4-overseas-pda-operations.mjs' +import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs' + +test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => { + const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [ + { positionId: '10', palletNo: 'REAL-A', availablePackages: 2 }, + { positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] } + const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [ + { positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] } + const selected = selectPhysicalRelabelSources([b, a, a], 'A') + assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B']) + assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2) +}) + +test('exception uses the untouched third carton, independent of response ordering', () => { + const third = { itemId: '3', expectedPackages: 1 } + const detail = { items: [third, { itemId: '2' }, { itemId: '1' }] } + assert.equal(remainingRelabelItem(detail, [1, '2']), third) + assert.equal(remainingRelabelItem(detail, ['1', '2', '3']), null) + assert.equal(remainingRelabelItem(null, []), null) +}) const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8') 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 c0b8c12e9..2855f8c70 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -87,7 +87,7 @@ async function expectConflict(context, token, method, name, path, body) { } async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') { - return request(context, token, 'POST', name, + return request(context, token, 'POST', `${name}(${palletCode})`, '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } @@ -103,6 +103,11 @@ async function resolvePublicLocation(context, fixture) { ? { locationId: id(location.id), locationCode: location.locationCode } : null } +export function remainingRelabelItem(detail, usedItemIds) { + const used = new Set(usedItemIds.map(id)) + return (detail?.items || []).find((item) => id(item?.itemId) && !used.has(id(item.itemId))) || 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 @@ -328,6 +333,27 @@ async function verifyPalletOperations(context, token, fixture) { return success(unbound.response) } +export function selectPhysicalRelabelSources(candidates, preferredBalanceId) { + const ordered = [...candidates].sort((a, b) => + Number(id(b.inventoryBalanceId) === id(preferredBalanceId)) - Number(id(a.inventoryBalanceId) === id(preferredBalanceId))) + const sourceLines = [] + const seen = new Set() + for (const source of ordered) { + if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue + for (const position of source.positions || []) { + const positionId = id(position?.positionId) + if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo) + || position.palletNo.toUpperCase() === 'UNBOUND') continue + seen.add(positionId) + const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages)) + if (!Number.isInteger(selected) || selected <= 0) continue + for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 }) + if (sourceLines.length === 3) return sourceLines + } + } + return sourceLines +} + async function verifyRelabel(context, token, merchantToken, fixture) { const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId) const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo @@ -338,33 +364,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) { return false } if (!await fundRelabelWallet(context, customerId)) return false - const inventoryCandidatesResponse = await context.requestOverseas('GET', query( - '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { - operationWarehouseId: fixture.overseasWarehouseId, inboundCode: fixture.inboundCode, - }), undefined, {}, merchantToken) - const inventoryCandidates = success(inventoryCandidatesResponse) - && Array.isArray(inventoryCandidatesResponse.body?.data) ? inventoryCandidatesResponse.body.data : [] - const orderedCandidates = [...inventoryCandidates].sort((left, right) => { - const leftPreferred = id(left?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0 - const rightPreferred = id(right?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0 - return rightPreferred - leftPreferred - }) - const sourceLines = [] - let requiredPackages = 3 - for (const source of orderedCandidates) { - if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue - for (const position of source?.positions || []) { - const available = Number(position?.availablePackages) - if (!id(position?.positionId) || available <= 0) continue - const selected = Math.min(requiredPackages, available) - for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) { - sourceLines.push({ source, position, packageCount: 1 }) - } - requiredPackages -= selected - if (requiredPackages === 0) break - } - if (requiredPackages === 0) break + // P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets, + // and may combine only the current completed container's same-destination inbounds. + const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || []) + .filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType + && item.fbaWarehouseCode === fixture.fbaWarehouseCode) + .map((item) => item.inboundCode).filter(nonBlank))] + const inventoryCandidates = [] + let inventoryCandidatesResponse = null + for (const inboundCode of inboundCodes) { + inventoryCandidatesResponse = await context.requestOverseas('GET', query( + '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { + operationWarehouseId: fixture.overseasWarehouseId, inboundCode, + }), undefined, {}, merchantToken) + context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse, + success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS) + if (!success(inventoryCandidatesResponse)) return false + inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) => + item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId))) } + const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId) + const requiredPackages = 3 - sourceLines.length context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse, requiredPackages === 0, `sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS) @@ -452,8 +472,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) { await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath, { ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') }) detail = scanned.data + if (!detail) return false const skipItem = Array.isArray(detail?.items) ? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null + if (!skipItem) { + blocker(context, 'P5跳过换标明细', '缺少独立的第二箱换标任务', scanned.response) + return false + } const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark, reason: `${context.marker}-skip`, expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-SKIP') } @@ -462,7 +487,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) { context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response, Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS) detail = skipped.data - const exceptionItem = item + if (!detail) return false + const exceptionItem = remainingRelabelItem(detail, [item.itemId, skipItem.itemId]) + if (!exceptionItem) { + blocker(context, 'P5异常换标明细', '缺少未被正常换标或跳过的第三箱任务', skipped.response) + return false + } const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null, exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-EXCEPTION') } @@ -473,6 +503,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response, Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS) detail = exception.data + if (!detail) return false const photo = await context.requestOverseasMultipart('POST', `${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b6268b4af..3f23972d0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -10,6 +10,10 @@ const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC function id(value) { return value == null ? '' : String(value) } function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +export function uniqueContainerProducer(item, index, all) { + return nonBlank(item?.containerNo) + && all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index +} function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response) { return String(response?.body?.code ?? '') } function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } @@ -897,9 +901,9 @@ export async function run(context) { const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') // The sealed dropship pallet proves the indivisible handover. A separate standard FBA // fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks. - const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba, + const producers = [dropship, standardFba, matrix?.byDestinationType?.OVERSEAS?.[0]] - .filter((item) => nonBlank(item?.containerNo)) + .filter(uniqueContainerProducer) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers if (allProducers.length) await awaitProducerConsumption(context, allProducers) if (producers.length && !(await convertReceivingContainers(context, producers))) {