From 64047df5b35de58ff762e25f5d8ef7d52353cf64 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:50:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReceivingContainerService.java | 28 ++++++-- .../openapi/overseas-business-v1.yaml | 4 +- .../ReceivingContainerControllerTest.java | 6 +- ...ReceivingContainerServiceBehaviorTest.java | 64 +++++++++++++++-- scripts/admin-test-env-modules/README.md | 4 ++ ...nt-qualification-fixture-contract.test.mjs | 1 + scripts/admin-test-env-modules/orders.mjs | 2 +- .../phase4-overseas-pda-contract.test.mjs | 26 +++++++ .../phase4-overseas-pda.mjs | 69 +++++++++++++++++-- scripts/agents-index-contract.ps1 | 9 ++- 10 files changed, 193 insertions(+), 20 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index d9c2e539f..f85f6ce6e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -229,7 +229,7 @@ public class ReceivingContainerService { CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); InboundRef ref = carton.ref(); ReceivingContainerResponses.Inbound inbound = inbound(ref); - return new ReceivingContainerResponses.CartonResolved(carton.carton().getCartonMark(), + return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), @@ -423,13 +423,33 @@ public class ReceivingContainerService { private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) { InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); - if (carton == null) throw notFound("Carton mark was not found"); + if (carton == null) return resolveFbaCartonCodeWithoutManifest(customerId, containerNo, cartonMark); List matches = refs(customerId, containerNo).stream() .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList(); if (matches.size() != 1) throw conflict("Carton mark belongs to another container"); - return new CartonRef(carton, matches.get(0)); + return new CartonRef(matches.get(0)); + } + + private CartonRef resolveFbaCartonCodeWithoutManifest(String customerId, String containerNo, String cartonMark) { + int delimiter = cartonMark == null ? -1 : cartonMark.indexOf('#'); + if (delimiter < 1) throw conflict("Carton mark must contain a non-empty inbound code prefix"); + String inboundCode = cartonMark.substring(0, delimiter).trim(); + if (inboundCode.isEmpty()) throw conflict("Carton mark must contain a non-empty inbound code prefix"); + List matches = refs(customerId, containerNo).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + if (matches.isEmpty()) { + boolean exists = lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId) + .eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent(); + if (exists) throw conflict("Carton mark belongs to another container"); + throw notFound("Carton mark was not found"); + } + if (matches.size() != 1) throw conflict("Carton mark inbound code is ambiguous in the current container"); + InboundRef ref = matches.get(0); + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) throw notFound("Carton mark was not found"); + return new CartonRef(ref); } private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { @@ -806,7 +826,7 @@ public class ReceivingContainerService { private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } - private record CartonRef(InboundPlanCarton carton, InboundRef ref) { } + private record CartonRef(InboundRef ref) { } private record SkuExpectation(String skuCode, String productName, int expectedPackages) { } private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { } private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index d39ab278b..40e76a822 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -72,10 +72,10 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: post: summary: 解析当前柜箱唛 - description: 权限 lastleg:pda:operate;FBA 和 OVERSEAS 均可解析当前锁定柜内箱码,返回唯一入仓码、目的类型、目标仓和实收进度;不返回 SKU。 + description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } - responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 箱码跨柜或归属不唯一 } } + responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: 缺少 #、空入仓码前缀、跨柜或当前柜多重命中 } } /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: post: summary: 解析当前柜实物托盘码 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index d374745c5..2183d612e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -100,10 +100,10 @@ class ReceivingContainerControllerTest { @Test void fbaCartonResolutionDelegatesWithoutControllerModeRejection() { ReceivingContainerRequests.CartonResolve request = - new ReceivingContainerRequests.CartonResolve("NYEXX2608260002#4-1"); + new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1"); when(service.resolveCarton("42", request)).thenReturn(new ReceivingContainerResponses.CartonResolved( - "NYEXX2608260002#4-1", "IN-1", "FBA", "900", "Shared LAX", null, null, null, - "ABE3", 1, 40, 0, 40)); + "NYEXX2608260001#4-1", "NYEXX2608260001", "FBA", "900", "Shared LAX", null, null, null, + "ABE3", 1, 4, 0, 4)); controller.resolveCarton(picker(), request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 83ea119ab..9d82c431a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -406,15 +406,69 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void fbaCartonResolutionForNyexx2608260002DoesNotApplyReceiptModeGate() { + void fbaCartonResolutionWithoutManifestForNyexx2608260001DoesNotWriteReceiptState() { stubCurrent("FBA", "CONT-A"); - when(cartons.selectOne(any())).thenReturn(carton(1L, "NYEXX2608260002#4-1")); + InboundPlanLine fba = line("FBA"); + fba.setInboundCode("NYEXX2608260001"); fba.setRequiredPackageCount(4); + when(lines.selectList(any())).thenReturn(List.of(fba)); - var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("NYEXX2608260002#4-1")); + var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1")); + var replay = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1")); - assertThat(resolved.cartonMark()).isEqualTo("NYEXX2608260002#4-1"); - assertThat(resolved.inboundCode()).isEqualTo("IN-1"); + assertThat(resolved.cartonMark()).isEqualTo("NYEXX2608260001#4-1"); + assertThat(resolved.inboundCode()).isEqualTo("NYEXX2608260001"); assertThat(resolved.destinationType()).isEqualTo("FBA"); + assertThat(replay).isEqualTo(resolved); + verifyNoInteractions(workflow, operations); + } + + @Test + void fbaCartonResolutionRejectsCrossContainerAndInvalidPrefixesAsConflicts() { + stubCurrent("FBA", "CONT-A"); + InboundPlanLine current = line("FBA"); + current.setInboundCode("NYEXX2608260001"); current.setRequiredPackageCount(4); + InboundPlanLine foreign = line("FBA"); + foreign.setInboundCode("NYEXX2608269999"); foreign.setPlanId(99L); + when(lines.selectList(any())).thenReturn(List.of(current), List.of(foreign)); + + assertThatThrownBy(() -> service.resolveCarton("7", + new ReceivingContainerRequests.CartonResolve("NYEXX2608269999#4-1"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode().value()).isEqualTo(409)); + assertThatThrownBy(() -> service.resolveCarton("7", + new ReceivingContainerRequests.CartonResolve("NYEXX2608260001"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode().value()).isEqualTo(409)); + assertThatThrownBy(() -> service.resolveCarton("7", + new ReceivingContainerRequests.CartonResolve("#4-1"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode().value()).isEqualTo(409)); + + org.mockito.Mockito.reset(lines, cartons); + when(lines.selectList(any())).thenReturn(List.of(current), List.of()); + assertThatThrownBy(() -> service.resolveCarton("7", + new ReceivingContainerRequests.CartonResolve("UNKNOWN#5-1"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode().value()).isEqualTo(404)); + + InboundPlanLine duplicate = line("FBA"); + duplicate.setId(20L); duplicate.setInboundCode("NYEXX2608260001"); + org.mockito.Mockito.reset(lines); + when(lines.selectList(any())).thenReturn(List.of(current, duplicate)); + assertThatThrownBy(() -> service.resolveCarton("7", + new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#any-tail"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode().value()).isEqualTo(409)); + } + + @Test + void overseasCartonResolutionWithoutManifestIsNotFound() { + stubCurrent("OVERSEAS", "CONT-A"); + + assertThatThrownBy(() -> service.resolveCarton("7", + new ReceivingContainerRequests.CartonResolve("IN-1#2-1"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode().value()).isEqualTo(404)); } @Test diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d17fe3a6e..69b2297d4 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 +订单造数中的 `GET /api/merchant/v1/partners/candidates?type=SHIPPER` 属于 Warehouse 正式客户候选能力。失败时订单脚本仍可使用 sender 快照继续收集后续链路证据,但 HTTP 503/`S0503` 必须保留为 deployment blocker,不能因订单、测量、分柜或装柜后续通过而降级为 coverage gap。 + 人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。 人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`:它读取分柜核验报告并冻结推送前计划版本、明细及无号模拟柜状态;人工点击“推送预装”后,再由 `manual-preload-push-verify.mjs` 只读核对 `EDITING` 预装清单、PENDING bridge、版本递增、明细零漂移和 PDA `A0404`。两个模块同样不进入默认全量入口。 @@ -88,6 +90,8 @@ export async function run(context) { 海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。 +FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。 + ```powershell node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 19330450f..2427b20b7 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -59,5 +59,6 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () => assert.match(orders, /destinationType === 'PRIVATE'/) assert.match(orders, /PRIVATE_ADDRESS/) assert.match(orders, /eligible\.length < 1/) + assert.match(orders, /Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS/) assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/) }) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index d3d3c5954..deecb0b78 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -290,7 +290,7 @@ async function loadMasterData(context, merchantToken) { const partnerResponse = await request(context, 'GET', '/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken) - const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse) + const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS) const partner = Array.isArray(partners) ? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER')) : null 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 c24c92b51..6c953b6b2 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 @@ -62,7 +62,18 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /FBA和OVERSEAS箱码resolve都必须HTTP 200\/code 200并返回当前柜唯一入仓码/) assert.match(source, /const matches = inbounds\.filter/) assert.match(source, /!Array\.isArray\(resolved\?\.inboundCode\)/) + assert.match(source, /matches\.length === 1/) assert.match(source, /destinations\.size === 1/) + assert.doesNotMatch(source, /\.split\([^\n]*['"]#['"]/) + assert.match(source, /`\$\{item\.inboundCode\}#PDA-CONTRACT-\$\{index \+ 1\}`/) + for (const negative of ['无#箱码被服务端拒绝', '空#前缀箱码被服务端拒绝', + '未命中入仓码前缀被服务端拒绝', '其他柜箱码前缀被当前锁定柜拒绝', + 'OVERSEAS缺少真实manifest行时不得按标准箱码推导']) { + assert.ok(source.includes(negative), negative) + } + assert.doesNotMatch(source, /FBA标准箱码序号超范围被拒绝/) + assert.match(source, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/) + assert.match(source, /receivingSnapshot\(afterResolve\.data\) === beforeResolve/) assert.match(source, /const fixturesConserved = overseasFixtures\.length === expectedPending/) assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/) assert.match(source, /for \(const \[index, fixture\] of overseasFixtures\.entries\(\)\)/) @@ -108,6 +119,21 @@ test('real-device legacy FBA carton rejection is retained as regression evidence assert.match(source, /a0409Message\(response, \/\^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden\$\//) }) +test('real-device deployed FBA resolve A0404 is retained until server-side inbound-prefix matching lands', () => { + const evidence = { + http: 200, + code: 'A0404', + message: 'Carton mark was not found', + requestId: 'a716e2c2-dc10-4d07-8014-7438afb63090', + traceId: '6ab8fcfcd812056ad6570d3b1652157b', + databaseFact: 'current FBA has no ll_inbound_plan_carton manifest', + } + assert.equal(evidence.code, 'A0404') + assert.equal(evidence.message, 'Carton mark was not found') + assert.match(evidence.databaseFact, /no ll_inbound_plan_carton manifest/) + assert.match(source, /a0404Message\(response, \/\^Carton mark was not found\$\//) +}) + test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => { const orderedTokens = [ "'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'", diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 402fe8888..506e5d001 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -13,12 +13,26 @@ function code(response) { return String(response?.body?.code ?? '') } function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) } function a0409Message(response, pattern) { return response?.http === 200 && code(response) === 'A0409' && traced(response) && pattern.test(String(response?.body?.message || '')) } +function a0404Message(response, pattern) { return response?.http === 200 && code(response) === 'A0404' && traced(response) && pattern.test(String(response?.body?.message || '')) } +function cartonResolveRejected(response) { return response?.http === 200 && ['A0400', 'A0404', 'A0409'].includes(code(response)) && traced(response) } function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) } function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } +function receivingSnapshot(data) { + return JSON.stringify({ + containerNo: data?.containerNo, status: data?.status, version: data?.version, + totalPackages: data?.totalPackages, receivedPackages: data?.receivedPackages, + pendingPackages: data?.pendingPackages, + inbounds: (Array.isArray(data?.inbounds) ? data.inbounds : []).map((item) => ({ + inboundCode: item?.inboundCode, destinationType: item?.destinationType, + totalPackages: item?.totalPackages, receivedPackages: item?.receivedPackages, + pendingPackages: item?.pendingPackages, + })).sort((left, right) => `${left.inboundCode}:${left.destinationType}`.localeCompare(`${right.inboundCode}:${right.destinationType}`)), + }) +} function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) @@ -293,10 +307,14 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer } async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) { - const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) - .map(id).filter(nonBlank))] + const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + .map(id).filter(nonBlank) + const fbaCartons = inbounds.filter((item) => item.destinationType === 'FBA') + .map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`) + const overseasCartons = inbounds.some((item) => item.destinationType === 'OVERSEAS') ? manifestCartons : [] + const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])] if (!cartonCandidates.length) { - blockerGap(context, '统一箱码解析链', '缺少本轮PDA任务冻结箱码,FBA和OVERSEAS均不能使用伪造箱号') + blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码') return null } const cartonFixtures = [] @@ -307,7 +325,7 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode) const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank)) const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode) - && matches.length > 0 && destinations.size === 1 + && matches.length === 1 && destinations.size === 1 && ['FBA', 'OVERSEAS'].includes(resolved?.destinationType) && destinations.has(resolved?.destinationType) context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response, @@ -323,6 +341,41 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar return cartonFixtures } +async function probeCartonResolveNegatives(context, token, current) { + const fba = current.inbounds.find((item) => item.destinationType === 'FBA') + await expected(context, token, 'POST', '无#箱码被服务端拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: fba?.inboundCode || `NO-HASH-${markerSuffix(context)}` }, cartonResolveRejected) + await expected(context, token, 'POST', '空#前缀箱码被服务端拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: `#${markerSuffix(context)}` }, cartonResolveRejected) + await expected(context, token, 'POST', '未命中入仓码前缀被服务端拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected) + + const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || [] + const crossContainerMark = allProducers + .filter((item) => id(item?.containerNo) !== id(current.containerNo)) + .flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark]) + .map(id).find(nonBlank) + if (crossContainerMark) { + await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: crossContainerMark }, cartonResolveRejected) + } else { + blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') + } + + const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS') + if (overseas) { + const total = Math.max(1, Number(overseas.totalPackages || 1)) + await expected(context, token, 'POST', 'OVERSEAS缺少真实manifest行时不得按标准箱码推导', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: `${overseas.inboundCode}#${total + 1}-1` }, + (response) => a0404Message(response, /^Carton mark was not found$/)) + } +} + async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) { const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode && item.resolved.destinationType === 'FBA') @@ -454,10 +507,18 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe { ...signBody, photos: [...photos].reverse() }, conflict) const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(current.response)) return + const beforeResolve = receivingSnapshot(current.data) + await probeCartonResolveNegatives(context, token, current.data) const producerCartonMarks = preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks) if (!cartonFixtures) return + const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入', + '/api/pda/v1/last-leg/receiving-containers/current') + context.assert(MODULE, 'FBA与OVERSEAS箱码resolve均为纯查询且零状态写入', afterResolve.response, + success(afterResolve.response) && receivingSnapshot(afterResolve.data) === beforeResolve, + `before=${beforeResolve}, after=${receivingSnapshot(afterResolve.data)}; ${trace(afterResolve.response)}`, OPTIONS) + if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return const pallets = new Map() for (const [index, inbound] of current.data.inbounds.entries()) { const pallet = await createPallet(context, inbound, index) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index f0b98b619..eeb4bcdaa 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -480,7 +480,14 @@ $criticalPaths = @( 'baiyue-firstleg/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs', 'baiyue-firstleg/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs', 'baiyue-firstleg/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs', - 'docs/四期/船节点标准化契约.md' + 'docs/四期/船节点标准化契约.md', + 'docs/一期/一期头程订单闭环设计.md', + 'docs/二期/二期需求优化版-v2-测量计费入库主流程.md', + 'docs/墨家设备测量上传接口说明-V3.md', + 'docs/三期/三期需求确认表.md', + 'docs/统一订单主状态与操作契约.md', + 'docs/财务管理/财务管理产品确认表.md', + 'docs/财务管理/Firstleg-Finance跨服务契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ }