From f2877df697f67f72593926a1cfebbb3aeddfe397 Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Wed, 26 Aug 2026 15:32:10 +0800 Subject: [PATCH 01/14] 1 --- .../com/baiyue/auth/service/AuthService.java | 3 ++- .../common/security/AuthenticatedUser.java | 4 +-- .../common/security/JwtClaimsMapper.java | 6 +++++ .../service/FinanceOperatorSnapshot.java | 4 +-- .../service/ReceivableStatisticsService.java | 27 ++++++++++--------- .../ReceivableStatisticsServiceTest.java | 4 +-- .../service/FinanceOperatorSnapshotTest.java | 10 +++++++ 7 files changed, 39 insertions(+), 19 deletions(-) diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 72a8dab9d..8760a7331 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -114,6 +114,7 @@ public class AuthService { .subject(account.getId().toString()) .claim("user_id", account.getId()) .claim("username", account.getUsername()) + .claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName()) .claim("tenant_id", account.getDefaultTenantId()) .claim("operator_tenant_id", account.getOperatorTenantId()) .claim("membership_id", account.getDefaultMembershipId()) @@ -176,7 +177,7 @@ public class AuthService { JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()) .audience(List.of(properties.audience())).id(jwtId) .issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString()) - .claim("user_id", account.getId()).claim("username", account.getUsername()) + .claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName()) .claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId()) .claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT") .claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion()) diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 1f4927303..61d675e30 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -7,12 +7,12 @@ import java.util.Set; * 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。 */ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, - String username, String appCode, String clientType, + String username, String displayName, String appCode, String clientType, Set permissions, boolean impersonated, Long actorUserId, Long actorMembershipId, boolean passwordChangeRequired) implements Principal { public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions) { - this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, + this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType, permissions, false, null, null, false); } public AuthenticatedUser { diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 84faded6a..59260406e 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -37,6 +37,7 @@ public class JwtClaimsMapper { longClaim(jwt, "operator_tenant_id"), longClaim(jwt, "membership_id"), username, + firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")), appCode, jwt.getClaimAsString("client_type"), permissions, @@ -86,4 +87,9 @@ public class JwtClaimsMapper { for (String value : values) if (value != null && !value.isBlank()) return value; return "unknown"; } + + private String firstNonBlankNullable(String... values) { + for (String value : values) if (value != null && !value.isBlank()) return value.trim(); + return null; + } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java index 8a70b1891..b531df958 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceOperatorSnapshot.java @@ -13,8 +13,8 @@ final class FinanceOperatorSnapshot { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user && user.userId() != null && user.userId() == operatorId - && StringUtils.hasText(user.username())) { - return user.username().trim(); + && (StringUtils.hasText(user.displayName()) || StringUtils.hasText(user.username()))) { + return StringUtils.hasText(user.displayName()) ? user.displayName().trim() : user.username().trim(); } return null; } diff --git a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java index e3edb6d99..07ce5c305 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReceivableStatisticsService.java @@ -41,23 +41,26 @@ public class ReceivableStatisticsService { LocalDate from = month.atDay(1); LocalDate to = month.plusMonths(1).atDay(1); StringBuilder sql = new StringBuilder(""" - SELECT tenant_id, customer_name_snapshot, - SUM(total_amount) total_receivable, - SUM(paid_amount) total_received, - SUM(outstanding_amount) total_outstanding - FROM fin_settlement - WHERE direction='RECEIVABLE' - AND status<>'VOIDED' - AND currency_code='CNY' - AND generated_date>=? - AND generated_date'VOIDED' + AND s.currency_code='CNY' + AND s.generated_date>=? + AND s.generated_date args = new ArrayList<>(List.of(from, to)); if (customerId != null) { - sql.append(" AND tenant_id=?"); + sql.append(" AND s.tenant_id=?"); args.add(customerId); } - sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id"); + sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id"); return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow( rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY, rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"), diff --git a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java index e583b3d81..b6709ad8e 100644 --- a/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReceivableStatisticsServiceTest.java @@ -40,8 +40,8 @@ class ReceivableStatisticsServiceTest { assertThat(result.records()).containsExactly(rows.get(0)); var sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class)); - assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'", - "generated_date>=?", "generated_date'VOIDED'", "s.currency_code='CNY'", + "s.generated_date>=?", "s.generated_date Date: Wed, 26 Aug 2026 16:50:22 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/AGENTS.md | 2 +- .../ReceivingContainerController.java | 8 + .../pda/dto/ReceivingContainerResponses.java | 2 + .../service/ReceivingContainerService.java | 6 + .../openapi/overseas-business-v1.yaml | 19 ++ .../pda/ReceivingContainerContractTest.java | 5 +- .../ReceivingContainerControllerTest.java | 11 + scripts/admin-test-env-acceptance.mjs | 60 +++- scripts/admin-test-env-modules/README.md | 15 + .../manual-loading-complete-contract.test.mjs | 45 +++ .../manual-loading-complete-fixture.mjs | 117 +++++++ .../manual-loading-complete-verify.mjs | 163 ++++++++++ .../manual-order-splitting-contract.test.mjs | 62 ++++ .../manual-order-splitting-fixture.mjs | 78 +++++ .../manual-order-splitting-verify.mjs | 97 ++++++ .../manual-pda-batch-contract.test.mjs | 43 +++ .../manual-pda-batch-fixture.mjs | 105 +++++++ .../manual-pda-batch-verify.mjs | 111 +++++++ .../manual-pda-ready-contract.test.mjs | 42 +++ .../manual-pda-ready-fixture.mjs | 89 ++++++ .../manual-pda-ready-verify.mjs | 122 ++++++++ .../manual-preload-push-contract.test.mjs | 41 +++ .../manual-preload-push-fixture.mjs | 70 +++++ .../manual-preload-push-verify.mjs | 106 +++++++ .../批量待分柜数据生成指南.md | 294 +++++++++++++++++- 25 files changed, 1704 insertions(+), 9 deletions(-) create mode 100644 scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs create mode 100644 scripts/admin-test-env-modules/manual-loading-complete-verify.mjs create mode 100644 scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs create mode 100644 scripts/admin-test-env-modules/manual-order-splitting-verify.mjs create mode 100644 scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs create mode 100644 scripts/admin-test-env-modules/manual-pda-batch-verify.mjs create mode 100644 scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs create mode 100644 scripts/admin-test-env-modules/manual-pda-ready-verify.mjs create mode 100644 scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/manual-preload-push-fixture.mjs create mode 100644 scripts/admin-test-env-modules/manual-preload-push-verify.mjs diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 033c79c9e..2e012ad41 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -24,7 +24,7 @@ | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 83f389c44..d614409c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -47,6 +47,14 @@ public class ReceivingContainerController { return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } + @GetMapping("/warehouse-profile") + @Operation(summary = "Get the authenticated warehouse picker's current Chinese warehouse master name") + public ApiResponse warehouseProfile( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.warehouseProfile(identity.overseasWarehouseId())); + } + @GetMapping("/{containerNo}") @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index f94f3f1ed..23a3be0bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -10,6 +10,8 @@ public final class ReceivingContainerResponses { List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean currentAccount) { } + public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { } + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, 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 74c938574..5aa3b65e0 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 @@ -87,6 +87,12 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } + public ReceivingContainerResponses.WarehouseProfile warehouseProfile(long overseasWarehouseId) { + WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + return new ReceivingContainerResponses.WarehouseProfile( + Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName()); + } + public List candidates(String operatorKey, long overseasWarehouseId) { ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); 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 f329e3eec..c059d4b88 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -9,6 +9,12 @@ paths: description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 security: [{ bearerAuth: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } + /api/pda/v1/last-leg/receiving-containers/warehouse-profile: + get: + summary: 查询当前PDA账号绑定的海外仓主档 + description: 权限 lastleg:pda:operate;仓库ID只取Token,名称与编码由共享仓库主档实时解析,不接收warehouseId参数。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前绑定仓库中文名称与编码, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWarehouseProfileApiResponse' } } } }, '403': { description: 账号未绑定有效海外仓 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 @@ -691,6 +697,13 @@ components: totalPackages: { type: integer } receivedPackages: { type: integer } pendingPackages: { type: integer } + ReceivingWarehouseProfile: + type: object + required: [warehouseId, warehouseCode, warehouseName] + properties: + warehouseId: { type: string, pattern: '^\d+$' } + warehouseCode: { type: string } + warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 } ReceivingCandidate: type: object required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount] @@ -777,6 +790,12 @@ components: - type: object required: [data] properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } } + ReceivingWarehouseProfileApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingWarehouseProfile' } } ReceivingSessionApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 5cb813bdd..2ac0fd471 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -124,6 +124,7 @@ class ReceivingContainerContractTest { String yaml = resource("openapi/overseas-business-v1.yaml"); String prefix = "/api/pda/v1/last-leg/receiving-containers"; assertTrue(yaml.contains(prefix + "/candidates:")); + assertTrue(yaml.contains(prefix + "/warehouse-profile:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); @@ -133,8 +134,8 @@ class ReceivingContainerContractTest { String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(16, successResponses.size()); - assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(17, successResponses.size()); + assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); 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 5936cc5b2..c87523822 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 @@ -45,6 +45,17 @@ class ReceivingContainerControllerTest { verify(service).candidates("42", 900L); } + @Test + void warehouseProfileUsesOnlyTheTokenBoundWarehouse() { + when(service.warehouseProfile(900L)).thenReturn( + new ReceivingContainerResponses.WarehouseProfile("900", "US-LAX-01", "洛杉矶一号仓")); + + var response = controller.warehouseProfile(picker()); + + assertThat(response.data().warehouseName()).isEqualTo("洛杉矶一号仓"); + verify(service).warehouseProfile(900L); + } + @Test void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() { AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7e214efc1..abb0069d8 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -6,12 +6,19 @@ import { pathToFileURL } from 'node:url' import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', + 'manual-preload-push-fixture', 'manual-preload-push-verify', + 'manual-pda-ready-fixture', 'manual-pda-ready-verify', + 'manual-pda-batch-fixture', 'manual-pda-batch-verify', + 'manual-loading-complete-fixture', 'manual-loading-complete-verify'] +const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], ['inbound-measurement', ['orders']], + ['manual-order-splitting-fixture', ['inbound-measurement']], ['phase3-container-loading', ['inbound-measurement']], ['phase3-loading', ['phase3-container-loading']], ['phase3-container-overview', ['phase3-loading']], @@ -243,6 +250,12 @@ function usage() { --channel-code 固定物流渠道编码 --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 + --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 + --manual-split-plan-id 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 + --manual-preload-source-report 人工推送预装前置或核验来源报告路径 + --manual-pda-source-report 人工订舱关联、预装确认前置或核验来源报告路径 + --manual-pda-batch-source-report PDA 人工领取、扫码批量提交前置或核验来源报告路径 + --manual-loading-complete-source-report 五类证据与 PDA 最终完成前置或核验来源报告路径 --output JSON 报告路径,默认 reports/ --help 显示帮助 @@ -267,9 +280,9 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } + const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' - const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) if (merchantUsername && merchantPassword) { const fixtureIndex = modules.indexOf('merchant-qualification-fixture') if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) @@ -287,12 +300,21 @@ export function parseArguments(argv) { channelName: values.get('channel-name') || '', channelCode: values.get('channel-code') || '', orderProfileMode: values.get('order-profile-mode') || 'fixed', - waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1), + waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT + || (modules.includes('manual-order-splitting-fixture') ? 6 : 1)), + manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', + manualSplitPlanId: values.get('manual-split-plan-id') || '', + manualPreloadSourceReport: values.get('manual-preload-source-report') || '', + manualPdaSourceReport: values.get('manual-pda-source-report') || '', + manualPdaBatchSourceReport: values.get('manual-pda-batch-source-report') || '', + manualLoadingCompleteSourceReport: values.get('manual-loading-complete-source-report') || '', output: values.get('output') || '', } } export function validateExecutionOptions(options) { + const unavailable = options.modules.filter((module) => !AVAILABLE_MODULES.has(module)) + if (unavailable.length > 0) throw new Error(`未注册的 HTTP 验收模块: ${unavailable.join(',')}`) for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) { if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`) } @@ -321,9 +343,39 @@ export function validateExecutionOptions(options) { throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') } if (options.waitSplitCount > 1) { - const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement']) + const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement', + 'manual-order-splitting-fixture']) if (options.modules.some((module) => !fixtureModules.has(module))) { - throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement') + throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture') + } + } + if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) { + throw new Error('manual-order-splitting-fixture要求wait-split-count至少为4') + } + if (options.modules.includes('manual-order-splitting-verify')) { + if (!options.manualSplitFixtureReport) throw new Error('manual-order-splitting-verify缺少manual-split-fixture-report') + if (!/^[1-9][0-9]*$/.test(String(options.manualSplitPlanId))) { + throw new Error('manual-order-splitting-verify要求manual-split-plan-id为正整数') + } + } + for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { + if (options.modules.includes(module) && !options.manualPreloadSourceReport) { + throw new Error(`${module}缺少manual-preload-source-report`) + } + } + for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { + if (options.modules.includes(module) && !options.manualPdaSourceReport) { + throw new Error(`${module}缺少manual-pda-source-report`) + } + } + for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { + if (options.modules.includes(module) && !options.manualPdaBatchSourceReport) { + throw new Error(`${module}缺少manual-pda-batch-source-report`) + } + } + for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) { + if (options.modules.includes(module) && !options.manualLoadingCompleteSourceReport) { + throw new Error(`${module}缺少manual-loading-complete-source-report`) } } return options diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 38908e6e9..4a6955f45 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,16 @@ 需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 +人工分柜采用两个可选模块,不进入默认全量入口:`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`。两个模块同样不进入默认全量入口。 + +预装推送通过后,`manual-pda-ready-fixture.mjs` 冻结无号、未关联订舱、不可确认且无 PDA 的基线;人工依次完成订舱 DRAFT、RELEASED 放舱、原模拟柜关联真实柜号和整份计划确认,再由 `manual-pda-ready-verify.mjs` 只读核对 RELEASED、allocation、稳定柜次/计划身份、CONFIRMED readiness 和唯一 `UNASSIGNED` PDA task。两个模块不领取任务,也不进入默认全量入口。 + +PDA 待领取通过后,`manual-pda-batch-fixture.mjs` 冻结任务明细、稳定箱码和库存快照基线;目标 PDA 作业员人工领取、逐箱扫码并一次提交原子 batch 后,`manual-pda-batch-verify.mjs` 只读核对作业员绑定、全量 PICKED、`AWAITING_REVIEW`、一次 CLAIM、一次 BATCH、pending/detail 一致和入库级数量收敛。HTTP 不记录逐次扫码轨迹,真机扫码仍需人工证据。 + +PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据、未完成、RELEASED 订舱和 LOADING 计划基线;人工现场上传五类证据并执行一次最终完成后,`manual-loading-complete-verify.mjs` 只读核对五类真实图片、唯一 COMPLETE_LOADING、任务/计划 COMPLETED、来源入库单和统一订单 LOADED、实装分片数量守恒及货柜总览终态。 + 例如 `--modules system,inbound-weight-ocr` 会依次加载: - `system.mjs` @@ -51,6 +61,11 @@ export async function run(context) { | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | +| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | +| 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` | +| 人工订舱关联、预装确认与 PDA 待领取核验 | `manual-pda-ready-fixture.mjs`、`manual-pda-ready-verify.mjs`(可选,不进入默认全量) | `manual-pda-ready-contract.test.mjs` | +| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` | +| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs b/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs new file mode 100644 index 000000000..5bc28b9d8 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-loading-complete-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-loading-complete-verify.mjs', import.meta.url), 'utf8') + +test('manual loading-complete modules are optional and require a source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-loading-complete-fixture'[^\]]*'manual-loading-complete-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-loading-complete-fixture'), false) + assert.equal(defaults.includes('manual-loading-complete-verify'), false) + for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, + '--manual-loading-complete-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }), + /manual-loading-complete-source-report/) + } +}) + +test('completion preparation is read only and freezes evidence, task, plan and booking gates', () => { + assert.match(fixture, /manualPdaBatchVerification/) + assert.match(fixture, /PHOTO_STEP_1.*PHOTO_STEP_2.*PHOTO_STEP_3.*PHOTO_STEP_4.*SEAL/) + assert.match(fixture, /activeEvidence\.length === 0/) + assert.match(fixture, /finalCompletionStatus === 'IN_PROGRESS'/) + assert.match(fixture, /oceanBooking\?\.status === 'RELEASED'/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('completion verifier proves five images, one completion audit and LOADED projections without writes', () => { + assert.match(verify, /task\?\.taskStatus === 'COMPLETED'/) + assert.match(verify, /activeEvidence\.length === fixture\.requiredEvidenceTypes\.length/) + assert.match(verify, /controlledImage\(download\)/) + assert.match(verify, /completeOperations\.length === 1/) + assert.match(verify, /plan\?\.status === 'COMPLETED'/) + assert.match(verify, /processStatus === 'LOADED'/) + assert.match(verify, /mainStatus === 'LOADED'/) + assert.match(verify, /splitRole === 'LOADED'/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs b/scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs new file mode 100644 index 000000000..7d79a07ca --- /dev/null +++ b/scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs @@ -0,0 +1,117 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-loading-complete-fixture' +const OPTIONS = { scenario: 'manual-loading-complete-fixture', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' +const REQUIRED_EVIDENCE_TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8')) + const source = report?.createdResources?.manualPdaBatchVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_AWAITING_REVIEW' + && source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId + context.assert(MODULE, '读取同环境 PDA 待复核核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? source : null +} + +function taskItems(value) { + return rows(value).map((item) => ({ id: id(item.id), tenantId: id(item.tenantId), + inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), + plannedPackageCount: Number(item.plannedPackageCount), pickedPackageCount: Number(item.pickedPackageCount), + itemStatus: item.itemStatus, version: Number(item.version) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +function sourceInboundSnapshots(items) { + const values = new Map() + for (const item of rows(items)) { + const inboundId = id(item.inboundId) + const current = values.get(inboundId) || { tenantId: id(item.tenantId), inboundId, pickedPackageCount: 0 } + current.pickedPackageCount += Number(item.pickedPackageCount || 0) + values.set(inboundId, current) + } + return [...values.values()] +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const taskId = id(source.pdaTaskId) + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const task = context.apiSuccess(MODULE, '最终完成前读取 PDA 待复核任务', taskResponse, '', OPTIONS) + const beforeItems = taskItems(task?.items) + const evidenceOwner = rows(task?.items).find((item) => id(item.inboundId) === id(task?.inboundId)) + || rows(task?.items)[0] + context.assert(MODULE, '任务全部拣齐、待复核且证据归属入库单明确', taskResponse, + id(task?.id) === taskId && task?.taskStatus === 'LOADING' + && task?.workflowStatus === 'AWAITING_REVIEW' && task?.assignment?.assigneeId + && beforeItems.length > 0 && beforeItems.every((item) => item.itemStatus === 'PICKED' + && item.pickedPackageCount === item.plannedPackageCount) + && evidenceOwner?.tenantId && evidenceOwner?.inboundId, + `workflowStatus=${task?.workflowStatus || 'missing'}, items=${beforeItems.length}, evidenceTenantId=${id(evidenceOwner?.tenantId) || 'missing'}, evidenceInboundId=${id(evidenceOwner?.inboundId) || 'missing'}`, + OPTIONS) + if (!evidenceOwner?.tenantId || !evidenceOwner?.inboundId) return + + const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(evidenceOwner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(evidenceOwner.tenantId)}` + const evidenceResponse = await context.request('GET', evidencePath) + const evidence = context.apiSuccess(MODULE, '上传五类证据前读取空基线', evidenceResponse, '', OPTIONS) + const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID') + context.assert(MODULE, '本轮最终完成前尚未上传 ACTIVE 装柜证据', evidenceResponse, + activeEvidence.length === 0, + `activeEvidence=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}`, OPTIONS) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(source.loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '最终完成前读取运行中装柜计划', planResponse, '', OPTIONS) + context.assert(MODULE, '计划保持 LOADING 且尚未最终完成', planResponse, + id(plan?.id) === id(source.loadingPlanId) && plan?.status === 'LOADING' + && plan?.finalCompletionStatus === 'IN_PROGRESS', + `status=${plan?.status || 'missing'}, finalCompletionStatus=${plan?.finalCompletionStatus || 'missing'}, version=${plan?.version ?? 'missing'}`, + OPTIONS) + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '最终完成前读取任务审计', operationsResponse, '', OPTIONS) + context.assert(MODULE, '完成前只有一次 CLAIM/BATCH 且无 COMPLETE_LOADING', operationsResponse, + rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1 + && rows(operations).filter((item) => item.actionType === 'BATCH').length === 1 + && !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`) + const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS) + const bookingResponse = container?.oceanBookingId + ? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`) + : containerResponse + const booking = context.apiSuccess(MODULE, '最终完成前回查订舱门禁', bookingResponse, '', OPTIONS) + context.assert(MODULE, '完成前真实柜号与 RELEASED 订舱仍有效', bookingResponse, + id(container?.id) === id(source.containerBookingId) && container?.containerNo === source.containerNo + && booking?.oceanBooking?.status === 'RELEASED', + `containerNo=${container?.containerNo || 'missing'}, oceanBookingId=${id(container?.oceanBookingId) || 'missing'}, bookingStatus=${booking?.oceanBooking?.status || 'missing'}`, + OPTIONS) + + context.createdResources.manualLoadingCompleteFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_EVIDENCE_AND_COMPLETE_READY', pdaTaskId: taskId, + loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId), + cabinetNo: source.cabinetNo, containerNo: source.containerNo, warehouseId: id(source.warehouseId), + evidenceTenantId: id(evidenceOwner.tenantId), evidenceInboundId: id(evidenceOwner.inboundId), + requiredEvidenceTypes: REQUIRED_EVIDENCE_TYPES, beforeTaskVersion: Number(task.version), + beforePlanVersion: Number(plan?.version), beforeItems, + sourceInbounds: sourceInboundSnapshots(task.items), + instructions: [ + '在 PDA 当前任务中依次现场拍摄空仓、半仓、满仓、锁仓和带锁码照五类证据。', + '五类分别对应 PHOTO_STEP_1、PHOTO_STEP_2、PHOTO_STEP_3、PHOTO_STEP_4、SEAL;SEAL 必须填写非空锁码。', + '确认五类证据均为 ACTIVE 后,只点击一次最终完成;不要调用旧入库单兼容完成入口。', + '完成成功后任务、计划和所有来源入库单应进入唯一终态,立即运行 manual-loading-complete-verify。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs b/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs new file mode 100644 index 000000000..be7667639 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs @@ -0,0 +1,163 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-loading-complete-verify' +const OPTIONS = { scenario: 'manual-loading-complete-readonly-verification', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualLoadingCompleteFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_EVIDENCE_AND_COMPLETE_READY' && fixture?.pdaTaskId + && Array.isArray(fixture?.requiredEvidenceTypes) && fixture.requiredEvidenceTypes.length === 5 + && Array.isArray(fixture?.sourceInbounds) && fixture.sourceInbounds.length > 0 + && fixture?.evidenceTenantId && fixture?.evidenceInboundId + context.assert(MODULE, '读取同环境装柜最终完成准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +function itemIdentity(value) { + return { id: id(value.id), tenantId: id(value.tenantId), inboundId: id(value.inboundId), + inboundDetailId: id(value.inboundDetailId), plannedPackageCount: Number(value.plannedPackageCount), + pickedPackageCount: Number(value.pickedPackageCount), itemStatus: value.itemStatus } +} + +function controlledImage(download) { + const type = String(download?.contentType || '').toLowerCase() + return download?.http === 200 && download?.bytes?.length > 0 + && type.startsWith('image/') && !type.includes('json') && !type.includes('html') +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const taskId = id(fixture.pdaTaskId) + + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, '读取 PDA 最终完成任务', taskResponse, '', OPTIONS) + const afterItems = rows(task?.items).map(itemIdentity) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) + const beforeItems = fixture.beforeItems.map(itemIdentity) + context.assert(MODULE, '最终完成后任务与 workflow 同为 COMPLETED 且明细身份不漂移', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId) + && id(task?.containerBookingId) === id(fixture.containerBookingId) + && task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED' + && Number(task?.version) > Number(fixture.beforeTaskVersion) + && JSON.stringify(afterItems) === JSON.stringify(beforeItems), + `taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`, + OPTIONS) + + const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(fixture.evidenceInboundId)}/loading/evidence?tenantId=${encodeURIComponent(fixture.evidenceTenantId)}` + const evidenceResponse = await context.request('GET', evidencePath) + const evidence = context.apiSuccess(MODULE, '读取 LOADED 后五类不可变证据', evidenceResponse, '', OPTIONS) + const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID') + const evidenceComplete = fixture.requiredEvidenceTypes.every((type) => + activeEvidence.filter((item) => item.evidenceType === type).length === 1) + && activeEvidence.length === fixture.requiredEvidenceTypes.length + && activeEvidence.some((item) => item.evidenceType === 'SEAL' && String(item.sealNo || '').trim()) + context.assert(MODULE, '五类 ACTIVE 证据各一份且 SEAL 锁码有效', evidenceResponse, + evidenceComplete, + `types=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}, sealNo=${activeEvidence.find((item) => item.evidenceType === 'SEAL')?.sealNo || 'missing'}`, + OPTIONS) + for (const item of activeEvidence) { + const path = String(item.previewUrl || '') + const controlled = path.startsWith('/api/') && !path.startsWith('//') && !path.includes('://') + const download = controlled ? await context.requestBinary('GET', path) : null + context.assert(MODULE, `${item.evidenceType} 证据可鉴权下载真实图片`, download, + controlled && controlledImage(download), + `evidenceId=${id(item.id)}, path=${path || 'missing'}, bytes=${download?.bytes?.length ?? 0}, contentType=${download?.contentType || 'missing'}`, + OPTIONS) + } + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '读取最终完成不可变操作审计', operationsResponse, '', OPTIONS) + const completeOperations = rows(operations).filter((item) => item.actionType === 'COMPLETE_LOADING') + context.assert(MODULE, '最终主链恰有一次 COMPLETE_LOADING 且保留原 CLAIM/BATCH', operationsResponse, + rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1 + && rows(operations).filter((item) => item.actionType === 'BATCH').length === 1 + && completeOperations.length === 1 && completeOperations[0]?.operationId + && completeOperations[0]?.operatorId && completeOperations[0]?.operatorName + && completeOperations[0]?.createdAt, + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, completeCount=${completeOperations.length}`, + OPTIONS) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(fixture.loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '读取最终完成装柜计划', planResponse, '', OPTIONS) + context.assert(MODULE, '原装柜计划进入 COMPLETED 且版本递增', planResponse, + id(plan?.id) === id(fixture.loadingPlanId) && plan?.status === 'COMPLETED' + && Number(plan?.version) > Number(fixture.beforePlanVersion), + `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}`, + OPTIONS) + + const orderFacts = [] + for (const source of fixture.sourceInbounds || []) { + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(source.inboundId)}?tenantId=${encodeURIComponent(source.tenantId)}`) + const inbound = context.apiSuccess(MODULE, `${source.inboundId} 回查 LOADED 入库终态`, inboundResponse, '', OPTIONS) + const orderId = id(inbound?.summary?.orderId) + context.assert(MODULE, `${source.inboundId} 入库状态和完成历史正确`, inboundResponse, + inbound?.summary?.processStatus === 'LOADED' && orderId + && rows(inbound?.statusHistory).some((item) => item.action === 'COMPLETE_LOADING' + && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED' + && item.operatorName), + `status=${inbound?.summary?.processStatus || 'missing'}, orderId=${orderId || 'missing'}, histories=${rows(inbound?.statusHistory).length}`, + OPTIONS) + if (!orderId) continue + const orderResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(source.tenantId)}`) + const order = context.apiSuccess(MODULE, `${orderId} 回查统一订单终态`, orderResponse, '', OPTIONS) + context.assert(MODULE, `${orderId} 统一订单详情投影为 LOADED`, orderResponse, + id(order?.summary?.orderId) === orderId && order?.summary?.mainStatus === 'LOADED', + `mainStatus=${order?.summary?.mainStatus || 'missing'}, orderNo=${order?.summary?.orderNo || 'missing'}`, + OPTIONS) + orderFacts.push({ tenantId: id(source.tenantId), inboundId: id(source.inboundId), orderId, + orderNo: order?.summary?.orderNo || null }) + } + + const tenantId = id(fixture.sourceInbounds?.[0]?.tenantId) + const slicesResponse = await context.request('GET', + `/api/platform/v1/orders/container-bookings/${encodeURIComponent(fixture.containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`) + const slices = context.apiSuccess(MODULE, '回查柜次最终实装分片', slicesResponse, '', OPTIONS) + const loadedSlices = rows(slices).filter((item) => item.splitRole === 'LOADED' + && id(item.containerBookingId) === id(fixture.containerBookingId) + && id(item.loadingPlanId) === id(fixture.loadingPlanId)) + const pickedPackages = fixture.sourceInbounds.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0) + context.assert(MODULE, '最终实装分片唯一且箱数等于 PDA 已拣总数', slicesResponse, + loadedSlices.length > 0 + && new Set(loadedSlices.map((item) => id(item.sourceOrderId))).size === loadedSlices.length + && loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) === pickedPackages, + `loadedSlices=${loadedSlices.length}, loadedPackages=${loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)}, pickedPackages=${pickedPackages}`, + OPTIONS) + + const overviewResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(fixture.containerBookingId)}/overview`) + const overview = context.apiSuccess(MODULE, '回查货柜最终总览', overviewResponse, '', OPTIONS) + const photoCounts = new Map(rows(overview?.pda?.photoCounts).map((item) => [item.evidenceType, Number(item.count)])) + context.assert(MODULE, '货柜总览汇总 COMPLETED PDA 和五类 ACTIVE 证据', overviewResponse, + id(overview?.loadingPlanId) === id(fixture.loadingPlanId) + && id(overview?.pda?.taskId) === taskId + && overview?.pda?.taskStatus === 'COMPLETED' && overview?.pda?.workflowStatus === 'COMPLETED' + && fixture.requiredEvidenceTypes.every((type) => Number(photoCounts.get(type)) >= 1) + && rows(overview?.pda?.photos).filter((item) => item.status === 'ACTIVE').length === 5, + `taskStatus=${overview?.pda?.taskStatus || 'missing'}, workflowStatus=${overview?.pda?.workflowStatus || 'missing'}, photoCounts=${JSON.stringify(Object.fromEntries(photoCounts))}`, + OPTIONS) + + context.createdResources.manualLoadingCompleteVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'LOADED', + pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId), + containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo, + containerNo: fixture.containerNo, completeOperationId: completeOperations[0]?.operationId || null, + evidenceInboundId: id(fixture.evidenceInboundId), evidenceIds: activeEvidence.map((item) => id(item.id)), + loadedSliceCount: loadedSlices.length, loadedPackageCount: pickedPackages, orders: orderFacts, + terminalNote: '国内装柜人工验收链已到 LOADED;后续报关、国内出仓和海外接柜属于后续节点。', + } +} diff --git a/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs new file mode 100644 index 000000000..c81b839ce --- /dev/null +++ b/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-order-splitting-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-order-splitting-verify.mjs', import.meta.url), 'utf8') + +test('manual splitting modules are optional registered entries with a WAIT_SPLIT producer dependency', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-order-splitting-fixture'[^\]]*'manual-order-splitting-verify'/s) + assert.match(entry, /\['manual-order-splitting-fixture', \['inbound-measurement'\]\]/) + assert.match(entry, /--manual-split-fixture-report/) + assert.match(entry, /--manual-split-plan-id/) + assert.match(entry, /manual-order-splitting-fixture[^\n]+waitSplitCount < 4/) +}) + +test('fixture defaults to six orders while verifier requires an explicit report and plan id', () => { + const defaultModules = parseArguments([]).modules + assert.equal(defaultModules.includes('manual-order-splitting-fixture'), false) + assert.equal(defaultModules.includes('manual-order-splitting-verify'), false) + const fixtureOptions = parseArguments([ + '--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', + '--modules', 'manual-order-splitting-fixture', + ]) + assert.deepEqual(fixtureOptions.modules, [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'manual-order-splitting-fixture', + ]) + assert.equal(fixtureOptions.waitSplitCount, 6) + assert.equal(validateExecutionOptions(fixtureOptions), fixtureOptions) + + const verifyOptions = parseArguments([ + '--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', + '--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json', + '--manual-split-plan-id', '123', + ]) + assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify']) + assert.equal(validateExecutionOptions(verifyOptions), verifyOptions) + assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }), + /manual-split-plan-id/) + assert.throws(() => validateExecutionOptions({ ...fixtureOptions, waitSplitCount: 3 }), + /wait-split-count至少为4/) +}) + +test('fixture prepares a partial, two full and one untouched scenario without performing split writes', () => { + assert.match(fixture, /role: 'PARTIAL'/) + assert.match(fixture, /role: `FULL_\$\{index \+ 1\}`/) + assert.match(fixture, /role: 'UNASSIGNED_CONTROL'/) + assert.match(fixture, /WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT/) + assert.doesNotMatch(fixture, /context\.request\(['"]POST/) +}) + +test('verifier is read only and checks plan, container, allocations and remaining candidates', () => { + assert.match(verify, /context\.request\('GET', `\$\{SMART\}\/plans/) + assert.match(verify, /context\.request\('GET', `\$\{CONTAINERS\}/) + assert.match(verify, /inbound-candidates\?bookingId=/) + assert.match(verify, /sourceGateway[\s\S]*sameGateway/) + assert.match(verify, /候选列表未出现异渠道或异仓入库单/) + assert.match(verify, /expectedAllocatedPackageCount/) + assert.match(verify, /expectedRemainingPackageCount/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs b/scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs new file mode 100644 index 000000000..f8325f3fe --- /dev/null +++ b/scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs @@ -0,0 +1,78 @@ +const MODULE = 'manual-order-splitting-fixture' +const OPTIONS = { scenario: 'manual-order-splitting-fixture', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function positiveInteger(value) { return Number.isInteger(Number(value)) && Number(value) > 0 } + +function normalizedFixture(value) { + return { + sequence: Number(value.sequence), tenantId: id(value.tenantId), orderId: id(value.orderId), + orderNo: value.orderNo || null, inboundId: id(value.inboundId), inboundCode: value.inboundCode || null, + warehouseId: id(value.receivingWarehouseId), warehouseName: value.receivingWarehouseName || null, + channelCode: value.channelCode || null, channelName: value.channelName || null, + totalPackageCount: Number(value.totalPackageCount), destinationType: value.destinationType || null, + fulfillmentType: value.fulfillmentType || null, containerMode: value.containerMode || null, + } +} + +export async function run(context) { + const fixtures = (context.createdResources?.waitSplitFixtures || []).map(normalizedFixture) + const valid = fixtures.length >= 4 && fixtures.every((item) => item.tenantId && item.orderId + && item.inboundId && item.warehouseId && item.channelCode && positiveInteger(item.totalPackageCount)) + context.assert(MODULE, '至少取得四票本轮 WAIT_SPLIT 人工分柜数据', null, valid, + `count=${fixtures.length}, invalid=${fixtures.filter((item) => !item.tenantId || !item.orderId + || !item.inboundId || !item.warehouseId || !item.channelCode || !positiveInteger(item.totalPackageCount)).length}`, + OPTIONS) + if (!valid) return + + const tenantIds = new Set(fixtures.map((item) => item.tenantId)) + const warehouseIds = new Set(fixtures.map((item) => item.warehouseId)) + const channelCodes = new Set(fixtures.map((item) => item.channelCode)) + const sameScope = tenantIds.size === 1 && warehouseIds.size === 1 && channelCodes.size === 1 + context.assert(MODULE, '人工分柜正向样本固定同一客户、起运仓和渠道', null, sameScope, + `tenantIds=${[...tenantIds].join(',')}, warehouseIds=${[...warehouseIds].join(',')}, channelCodes=${[...channelCodes].join(',')}`, + OPTIONS) + if (!sameScope) return + + const byPackages = [...fixtures].sort((left, right) => right.totalPackageCount - left.totalPackageCount + || left.sequence - right.sequence) + const partial = byPackages.find((item) => item.totalPackageCount >= 2) + const remaining = fixtures.filter((item) => item.inboundId !== partial?.inboundId) + const full = remaining.slice(0, 2) + const control = remaining[2] + const scenarioReady = Boolean(partial && full.length === 2 && control) + context.assert(MODULE, '人工分柜场景包含部分分配、两票整票拼柜和未分配对照', null, scenarioReady, + `partial=${partial?.inboundCode || 'missing'}, full=${full.map((item) => item.inboundCode).join(',') || 'missing'}, control=${control?.inboundCode || 'missing'}`, + OPTIONS) + if (!scenarioReady) return + + const assignments = [ + { role: 'PARTIAL', ...partial, expectedAllocatedPackageCount: partial.totalPackageCount - 1, + expectedRemainingPackageCount: 1 }, + ...full.map((item, index) => ({ role: `FULL_${index + 1}`, ...item, + expectedAllocatedPackageCount: item.totalPackageCount, expectedRemainingPackageCount: 0 })), + ] + const manifest = { + schemaVersion: 1, marker: context.marker, stage: 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT', + tenantId: fixtures[0].tenantId, warehouseId: fixtures[0].warehouseId, + warehouseName: fixtures[0].warehouseName, channelCode: fixtures[0].channelCode, + channelName: fixtures[0].channelName, + adminEntry: '/container-loading', + instructions: [ + '在智能装柜页面创建一个同仓同渠道的内部模拟柜。', + '创建智能装柜计划,并严格按 assignments 的 expectedAllocatedPackageCount 分配。', + '保留 unassignedControl 不加入计划,用于核对候选剩余与误选保护。', + '人工操作后记录 loadingPlanId,并运行 manual-order-splitting-verify 只读核验。', + ], + assignments, + unassignedControl: { role: 'UNASSIGNED_CONTROL', ...control, + expectedAllocatedPackageCount: 0, expectedRemainingPackageCount: control.totalPackageCount }, + allFixtures: fixtures, + } + context.createdResources.manualOrderSplittingFixture = manifest + context.assert(MODULE, '人工分柜清单数量守恒', null, + assignments.every((item) => item.totalPackageCount + === item.expectedAllocatedPackageCount + item.expectedRemainingPackageCount), + `assignments=${assignments.length}, plannedPackages=${assignments.reduce((sum, item) => sum + item.expectedAllocatedPackageCount, 0)}`, + OPTIONS) +} diff --git a/scripts/admin-test-env-modules/manual-order-splitting-verify.mjs b/scripts/admin-test-env-modules/manual-order-splitting-verify.mjs new file mode 100644 index 000000000..c41ba6fd1 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-order-splitting-verify.mjs @@ -0,0 +1,97 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-order-splitting-verify' +const OPTIONS = { scenario: 'manual-order-splitting-readonly-verification', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadManifest(context) { + const path = resolve(context.options.manualSplitFixtureReport) + const report = JSON.parse(await readFile(path, 'utf8')) + const manifest = report?.createdResources?.manualOrderSplittingFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const sameGateway = sourceGateway && sourceGateway === context.options.gateway + context.assert(MODULE, '读取人工分柜准备报告', null, + manifest?.schemaVersion === 1 && manifest?.stage === 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT' + && Array.isArray(manifest.assignments) && manifest.assignments.length === 3 && sameGateway, + `sourceMarker=${report?.marker || 'missing'}, schemaVersion=${manifest?.schemaVersion ?? 'missing'}, assignments=${manifest?.assignments?.length ?? 'missing'}, sameGateway=${Boolean(sameGateway)}`, + OPTIONS) + return sameGateway ? manifest : null +} + +export async function run(context) { + const manifest = await loadManifest(context) + if (!manifest?.assignments?.length) return + + const planId = id(context.options.manualSplitPlanId) + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '读取人工操作后的智能装柜计划', planResponse, + `loadingPlanId=${planId}`, OPTIONS) + if (!plan) return + + const bookingId = id(plan.bookingId || plan.containerBookingId) + const planItems = rows(plan.items) + context.assert(MODULE, '智能装柜计划身份与状态可继续人工验收', planResponse, + id(plan.id) === planId && bookingId && ['EDITING', 'LOADING'].includes(plan.status), + `planId=${id(plan.id) || 'missing'}, bookingId=${bookingId || 'missing'}, status=${plan.status || 'missing'}, version=${plan.version ?? 'missing'}`, + OPTIONS) + if (!bookingId) return + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`) + const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS) + context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse, + id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId) + && container?.channelCode === manifest.channelCode, + `containerId=${id(container?.id) || 'missing'}, warehouseId=${id(container?.warehouseId) || 'missing'}, channelCode=${container?.channelCode || 'missing'}`, + OPTIONS) + + const expectedByInbound = new Map(manifest.assignments.map((item) => [id(item.inboundId), item])) + for (const expected of manifest.assignments) { + const matches = planItems.filter((item) => id(item.inboundId) === id(expected.inboundId)) + const allocated = matches.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) + context.assert(MODULE, `${expected.role} 按清单完成计划分配`, planResponse, + matches.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount), + `inboundCode=${expected.inboundCode || 'missing'}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, itemCount=${matches.length}`, + OPTIONS) + } + + const foreignItems = planItems.filter((item) => !expectedByInbound.has(id(item.inboundId))) + const controlItems = planItems.filter((item) => id(item.inboundId) === id(manifest.unassignedControl?.inboundId)) + context.assert(MODULE, '计划未混入清单外或未分配对照入库单', planResponse, + foreignItems.length === 0 && controlItems.length === 0, + `foreignInboundIds=${foreignItems.map((item) => id(item.inboundId)).join(',') || 'none'}, controlItemCount=${controlItems.length}`, + OPTIONS) + + const candidatesPath = `${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(manifest.warehouseId)}` + const candidatesResponse = await context.request('GET', candidatesPath) + const candidates = context.apiSuccess(MODULE, '回查人工分柜后的入仓码候选余量', candidatesResponse, '', OPTIONS) + const candidateRows = rows(candidates) + const candidateScopeValid = candidateRows.every((item) => (!item.channelCode + || item.channelCode === manifest.channelCode) + && (!item.warehouseId || id(item.warehouseId) === id(manifest.warehouseId))) + context.assert(MODULE, '候选列表未出现异渠道或异仓入库单', candidatesResponse, + candidateScopeValid, + `candidateCount=${candidateRows.length}, channelCode=${manifest.channelCode}, warehouseId=${manifest.warehouseId}`, + OPTIONS) + for (const expected of [...manifest.assignments, manifest.unassignedControl]) { + const candidate = candidateRows.find((item) => id(item.inboundId) === id(expected.inboundId)) + const actualRemaining = candidate ? Number(candidate.availablePackageCount) : 0 + context.assert(MODULE, `${expected.role} 分配后剩余箱数正确`, candidatesResponse, + actualRemaining === Number(expected.expectedRemainingPackageCount), + `inboundCode=${expected.inboundCode || 'missing'}, expectedRemaining=${expected.expectedRemainingPackageCount}, actualRemaining=${actualRemaining}`, + OPTIONS) + } + + context.createdResources.manualOrderSplittingVerification = { + sourceMarker: manifest.marker, verifiedAt: new Date().toISOString(), loadingPlanId: planId, + containerBookingId: bookingId, tenantId: id(manifest.tenantId), warehouseId: id(manifest.warehouseId), + channelCode: manifest.channelCode, planStatus: plan.status, planVersion: Number(plan.version), + verifiedAssignments: manifest.assignments.map((item) => ({ role: item.role, orderId: id(item.orderId), + inboundId: id(item.inboundId), expectedAllocatedPackageCount: Number(item.expectedAllocatedPackageCount) })), + note: '本模块只读核验计划分柜;父子订单在 PDA 实装结算后另行核验。', + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs b/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs new file mode 100644 index 000000000..fdb36948e --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-pda-batch-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-pda-batch-verify.mjs', import.meta.url), 'utf8') + +test('manual PDA batch modules are optional and require a source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-batch-fixture'[^\]]*'manual-pda-batch-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-pda-batch-fixture'), false) + assert.equal(defaults.includes('manual-pda-batch-verify'), false) + for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }), + /manual-pda-batch-source-report/) + } +}) + +test('PDA batch preparation is read only and preserves stable carton scan instructions', () => { + assert.match(fixture, /manualPdaReadyVerification/) + assert.match(fixture, /boxCodes\.length >= item\.plannedPackageCount/) + assert.match(fixture, /scanBoxCodes: rows\(item\.boxCodes\)/) + assert.match(fixture, /inventory-snapshot/) + assert.match(fixture, /不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING/) + assert.match(fixture, /逐次扫码动作需保留真机人工证据/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('post-batch verifier proves one claim, one batch and AWAITING_REVIEW without writes', () => { + assert.match(verify, /workflowStatus === 'AWAITING_REVIEW'/) + assert.match(verify, /itemStatus === 'PICKED'/) + assert.match(verify, /claimOperations\.length === 1 && batchOperations\.length === 1/) + assert.match(verify, /pendingItemCount\) === 0/) + assert.match(verify, /expectedCount\) === Number\(projection\?\.pickedCount\)/) + assert.match(verify, /HTTP 不记录逐次扫码轨迹/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs b/scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs new file mode 100644 index 000000000..d484ab2ac --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-batch-fixture' +const OPTIONS = { scenario: 'manual-pda-batch-fixture', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8')) + const source = report?.createdResources?.manualPdaReadyVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_UNASSIGNED' + && source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId + context.assert(MODULE, '读取同环境 PDA 待领取核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? source : null +} + +function taskItemSnapshot(items) { + return rows(items).map((item) => ({ + id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId), tenantId: id(item.tenantId), + inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), + orderNo: item.orderNo || null, inboundCode: item.inboundCode || null, cargoName: item.cargoName || null, + boxCodes: rows(item.boxCodes).map(String), + scanBoxCodes: rows(item.boxCodes).map(String).slice(0, Number(item.plannedPackageCount)), + plannedPackageCount: Number(item.plannedPackageCount), + })).sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const taskId = id(source.pdaTaskId) + + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const task = context.apiSuccess(MODULE, 'PDA 人工作业前读取稳定任务详情', taskResponse, '', OPTIONS) + const taskItems = taskItemSnapshot(task?.items) + const scanBoxCodes = taskItems.flatMap((item) => item.scanBoxCodes) + const cartonCodesReady = taskItems.length > 0 && taskItems.every((item) => item.id && item.inboundId + && item.plannedPackageCount > 0 && item.boxCodes.length >= item.plannedPackageCount + && item.scanBoxCodes.length === item.plannedPackageCount + && new Set(item.boxCodes).size === item.boxCodes.length) + && new Set(scanBoxCodes).size === scanBoxCodes.length + context.assert(MODULE, '待领取任务下发稳定箱码且计划箱数可逐箱扫描', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === id(source.loadingPlanId) + && id(task?.containerBookingId) === id(source.containerBookingId) + && task?.taskStatus === 'LOADING' && task?.workflowStatus === 'UNASSIGNED' + && task?.assignment == null && cartonCodesReady, + `workflowStatus=${task?.workflowStatus || 'missing'}, items=${taskItems.length}, plannedPackages=${taskItems.reduce((sum, item) => sum + item.plannedPackageCount, 0)}, boxCodes=${taskItems.reduce((sum, item) => sum + item.boxCodes.length, 0)}`, + OPTIONS) + if (!cartonCodesReady || task?.workflowStatus !== 'UNASSIGNED') return + + const pendingResponse = await context.request('GET', + `${PDA}/tasks/pending?keyword=${encodeURIComponent(source.containerNo)}`) + const pending = context.apiSuccess(MODULE, '作业前读取当前操作员 PDA 待办', pendingResponse, '', OPTIONS) + const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId) + context.assert(MODULE, '未领取任务在 PDA 待办中唯一可见', pendingResponse, + Boolean(pendingTask) && pendingTask.workflowStatus === 'UNASSIGNED' + && Number(pendingTask.pendingItemCount) === taskItems.length, + `matches=${rows(pending).filter((item) => id(item.taskId) === taskId).length}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`, + OPTIONS) + + const inventoryResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const inventory = context.apiSuccess(MODULE, '作业前读取实时库存快照基线', inventoryResponse, '', OPTIONS) + const inventoryReady = id(inventory?.taskId) === taskId + && id(inventory?.loadingPlanId) === id(source.loadingPlanId) + && id(inventory?.containerBookingId) === id(source.containerBookingId) + && Number(inventory?.taskVersion) === Number(task?.version) + && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 + && rows(inventory?.inbounds).length > 0 + context.assert(MODULE, '库存快照与待领取任务版本和四元身份一致', inventoryResponse, + inventoryReady, + `taskVersion=${task?.version ?? 'missing'}, snapshotTaskVersion=${inventory?.taskVersion ?? 'missing'}, inboundCount=${rows(inventory?.inbounds).length}`, + OPTIONS) + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '作业前读取 PDA 操作历史', operationsResponse, '', OPTIONS) + context.assert(MODULE, '作业前不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING', operationsResponse, + rows(operations).every((item) => !['CLAIM', 'BATCH', 'REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, + OPTIONS) + + context.createdResources.manualPdaBatchFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', pdaTaskId: taskId, + loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId), + cabinetNo: source.cabinetNo, containerNo: source.containerNo, tenantId: id(source.tenantId), + warehouseId: id(source.warehouseId), beforeTaskVersion: Number(task.version), taskItems, + inventoryBaseline: rows(inventory?.inbounds).map((item) => ({ inboundId: id(item.inboundId), + availablePackageCount: Number(item.availablePackageCount), resourceCount: rows(item.resources).length })), + instructions: [ + '使用目标 PDA 作业员进入待办并领取本任务,确认任务进入 IN_PROGRESS。', + '按 taskItems.scanBoxCodes 在真机逐箱扫描;每个箱码只扫描一次,处理完全部计划箱数。', + '点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch;不要使用逐明细兼容接口。', + '提交成功停在 AWAITING_REVIEW,不上传装柜证据、不复核、不执行最终完成。', + 'HTTP 只能核验箱码下发和服务端数量,逐次扫码动作需保留真机人工证据。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-batch-verify.mjs b/scripts/admin-test-env-modules/manual-pda-batch-verify.mjs new file mode 100644 index 000000000..dfa83523a --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-batch-verify.mjs @@ -0,0 +1,111 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-batch-verify' +const OPTIONS = { scenario: 'manual-pda-batch-readonly-verification', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualPdaBatchFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' && fixture?.pdaTaskId + && Array.isArray(fixture?.taskItems) && fixture.taskItems.length > 0 + context.assert(MODULE, '读取同环境 PDA 人工作业准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +function stableItem(value) { + return { id: id(value.id), loadingPlanItemId: id(value.loadingPlanItemId), tenantId: id(value.tenantId), + inboundId: id(value.inboundId), inboundDetailId: id(value.inboundDetailId), + boxCodes: rows(value.boxCodes).map(String), plannedPackageCount: Number(value.plannedPackageCount) } +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const taskId = id(fixture.pdaTaskId) + + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, '读取人工领取和批量提交后的 PDA 任务', taskResponse, '', OPTIONS) + const afterItems = rows(task?.items).map(stableItem) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) + const beforeStable = fixture.taskItems.map(stableItem) + const allPicked = rows(task?.items).length > 0 && rows(task?.items).every((item) => + item.itemStatus === 'PICKED' + && Number(item.pickedPackageCount) === Number(item.plannedPackageCount)) + context.assert(MODULE, '领取后任务绑定作业员并以一次批量提交进入待复核', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId) + && id(task?.containerBookingId) === id(fixture.containerBookingId) + && task?.cabinetNo === fixture.cabinetNo && task?.currentContainerNo === fixture.containerNo + && task?.taskStatus === 'LOADING' && task?.workflowStatus === 'AWAITING_REVIEW' + && task?.assignment?.assigneeId && task?.assignment?.assigneeName + && Number(task?.version) > Number(fixture.beforeTaskVersion) + && JSON.stringify(afterItems) === JSON.stringify(beforeStable) && allPicked, + `taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, assigneeId=${id(task?.assignment?.assigneeId) || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`, + OPTIONS) + if (!task || task.workflowStatus !== 'AWAITING_REVIEW') return + + const pendingResponse = await context.request('GET', + `${PDA}/tasks/pending?keyword=${encodeURIComponent(fixture.containerNo)}`) + const pending = context.apiSuccess(MODULE, '读取待复核 PDA pending 投影', pendingResponse, '', OPTIONS) + const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId) + context.assert(MODULE, 'pending 与详情一致显示 AWAITING_REVIEW 且无未处理明细', pendingResponse, + Boolean(pendingTask) && pendingTask.workflowStatus === 'AWAITING_REVIEW' + && Number(pendingTask.itemCount) === rows(task.items).length + && Number(pendingTask.pendingItemCount) === 0, + `workflowStatus=${pendingTask?.workflowStatus || 'missing'}, itemCount=${pendingTask?.itemCount ?? 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`, + OPTIONS) + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '读取领取与原子批量提交审计', operationsResponse, '', OPTIONS) + const claimOperations = rows(operations).filter((item) => item.actionType === 'CLAIM') + const batchOperations = rows(operations).filter((item) => item.actionType === 'BATCH') + context.assert(MODULE, '人工主链只保留一次 CLAIM 和一次 BATCH 且尚无复核完成记录', operationsResponse, + claimOperations.length === 1 && batchOperations.length === 1 + && [...claimOperations, ...batchOperations].every((item) => item.operationId + && item.operatorId && item.operatorName && item.createdAt) + && !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, claimCount=${claimOperations.length}, batchCount=${batchOperations.length}`, + OPTIONS) + + const uniqueInbounds = [...new Map(rows(task.items).map((item) => [id(item.inboundId), item])).values()] + for (const item of uniqueInbounds) { + const projectionResponse = await context.request('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/picking?tenantId=${encodeURIComponent(item.tenantId)}`) + const projection = context.apiSuccess(MODULE, `回查 ${item.inboundCode || item.inboundId} 入库级拣货投影`, projectionResponse, '', OPTIONS) + context.assert(MODULE, `${item.inboundCode || item.inboundId} 服务端汇总数量已拣齐`, projectionResponse, + Number(projection?.expectedCount) > 0 + && Number(projection?.expectedCount) === Number(projection?.pickedCount), + `status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, + OPTIONS) + const evidenceResponse = await context.request('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(item.tenantId)}`) + const evidence = context.apiSuccess(MODULE, `${item.inboundCode || item.inboundId} 待复核取证入口可读`, evidenceResponse, '', OPTIONS) + context.assert(MODULE, `${item.inboundCode || item.inboundId} AWAITING_REVIEW 可进入取证`, evidenceResponse, + Array.isArray(evidence?.evidences), + `evidenceCount=${evidence?.evidences?.length ?? 'missing'}`, OPTIONS) + } + + context.createdResources.manualPdaBatchVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_AWAITING_REVIEW', + pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId), + containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo, + containerNo: fixture.containerNo, warehouseId: id(fixture.warehouseId), taskVersion: Number(task.version), + assigneeId: id(task.assignment?.assigneeId), assigneeName: task.assignment?.assigneeName || null, + itemCount: rows(task.items).length, + plannedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0), + pickedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0), + claimOperationId: claimOperations[0]?.operationId || null, + batchOperationId: batchOperations[0]?.operationId || null, + deviceEvidenceRequired: 'HTTP 不记录逐次扫码轨迹;箱码逐一扫描需保留真机人工验收证据。', + nextStage: '上传五类 ACTIVE 装柜证据后,再执行 PDA 最终完成与 LOADED 核验。', + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs b/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs new file mode 100644 index 000000000..2cd64f7ca --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-pda-ready-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-pda-ready-verify.mjs', import.meta.url), 'utf8') + +test('manual PDA-ready modules are optional and require a source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-ready-fixture'[^\]]*'manual-pda-ready-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-pda-ready-fixture'), false) + assert.equal(defaults.includes('manual-pda-ready-verify'), false) + for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }), + /manual-pda-source-report/) + } +}) + +test('PDA-ready preparation is read only and proves all three prerequisites are absent', () => { + assert.match(fixture, /manualPreloadPushVerification/) + assert.match(fixture, /container\?\.containerNo == null/) + assert.match(fixture, /container\?\.oceanBookingId == null/) + assert.match(fixture, /readiness\?\.canConfirm === false/) + assert.match(fixture, /codeOf\(taskResponse\) === 'A0404'/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('PDA-ready verifier checks release, stable link, confirmation and one unassigned task without writes', () => { + assert.match(verify, /oceanBooking\?\.status === 'RELEASED'/) + assert.match(verify, /positiveId\(allocation\?\.requirementId\)/) + assert.match(verify, /confirmationStatus === 'CONFIRMED'/) + assert.match(verify, /readyForPda === true/) + assert.match(verify, /workflowStatus === 'UNASSIGNED'/) + assert.match(verify, /processStatus === 'WAIT_SPLIT'/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs new file mode 100644 index 000000000..5d483e5f6 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs @@ -0,0 +1,89 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-ready-fixture' +const OPTIONS = { scenario: 'manual-pda-ready-fixture', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const CONTAINERS = '/api/platform/v1/containers' +const PDA = `${FIRST_LEG}/pda-loading` + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function items(value) { + return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8')) + const source = report?.createdResources?.manualPreloadPushVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && source?.stage === 'PRELOAD_EDITING' + && source?.loadingPlanId && source?.containerBookingId && source?.pdaTaskCreated === false + && Array.isArray(source?.expectedAssignments) && source.expectedAssignments.length > 0 + context.assert(MODULE, '读取同环境推送预装核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(source?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? source : null +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const planId = id(source.loadingPlanId) + const containerId = id(source.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, 'PDA 就绪操作前读取预装计划', planResponse, '', OPTIONS) + const beforeItems = items(plan?.items) + context.assert(MODULE, '操作前计划仍为无号已推送 EDITING', planResponse, + id(plan?.id) === planId && plan?.status === 'EDITING' && plan?.containerNo == null + && Number(plan?.version) === Number(source.planVersion) && beforeItems.length > 0, + `status=${plan?.status || 'missing'}, expectedVersion=${source.planVersion}, actualVersion=${plan?.version ?? 'missing'}, items=${beforeItems.length}`, + OPTIONS) + if (!plan || beforeItems.length === 0) return + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS) + context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse, + id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo + && container?.containerNo == null && container?.oceanBookingId == null + && Number.isInteger(Number(container?.version)), + `cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}, version=${container?.version ?? 'missing'}`, + OPTIONS) + + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const detail = context.apiSuccess(MODULE, '操作前读取预装确认门禁', detailResponse, '', OPTIONS) + context.assert(MODULE, '缺少真实柜号与 RELEASED 订舱时不能确认预装', detailResponse, + detail?.summary?.status === 'EDITING' && detail?.summary?.readiness?.canConfirm === false + && detail?.summary?.readiness?.readyForPda === false + && detail?.summary?.readiness?.pdaTaskId == null, + `canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}, blockers=${rows(detail?.summary?.readiness?.blockers).join(',')}`, + OPTIONS) + + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(source.warehouseId)}`) + context.addStep(MODULE, '操作前 PDA 任务仍不存在', taskResponse, + taskResponse.http === 200 && codeOf(taskResponse) === 'A0404', + `HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}`, OPTIONS) + + context.createdResources.manualPdaReadyFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_BOOKING_LINK_CONFIRM_READY', loadingPlanId: planId, containerBookingId: containerId, + cabinetNo: source.cabinetNo, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId), + channelCode: source.channelCode, beforePlanVersion: Number(plan.version), + beforeContainerVersion: Number(container?.version), beforeItems, + expectedAssignments: source.expectedAssignments || [], + instructions: [ + '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写完整运输资料。', + '确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。', + '在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。', + '回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。', + '记录 bookingNo、containerNo,并立即运行 manual-pda-ready-verify。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-ready-verify.mjs b/scripts/admin-test-env-modules/manual-pda-ready-verify.mjs new file mode 100644 index 000000000..d268ef348 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-ready-verify.mjs @@ -0,0 +1,122 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-ready-verify' +const OPTIONS = { scenario: 'manual-pda-ready-readonly-verification', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const CONTAINERS = '/api/platform/v1/containers' +const PDA = `${FIRST_LEG}/pda-loading` + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function positiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) } +function itemSnapshot(value) { + return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualPdaReadyFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_BOOKING_LINK_CONFIRM_READY' && fixture?.loadingPlanId + && fixture?.containerBookingId && Array.isArray(fixture?.beforeItems) + && Array.isArray(fixture?.expectedAssignments) && fixture.expectedAssignments.length > 0 + context.assert(MODULE, '读取同环境 PDA 就绪准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const planId = id(fixture.loadingPlanId) + const containerId = id(fixture.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '读取预装确认后的原智能装柜计划', planResponse, '', OPTIONS) + const afterItems = itemSnapshot(plan?.items) + context.assert(MODULE, '订舱关联和预装确认后计划身份、状态及明细稳定', planResponse, + id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId + && plan?.status === 'EDITING' && Number(plan?.version) > Number(fixture.beforePlanVersion) + && JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems), + `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`, + OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS) + const oceanBookingId = id(container?.oceanBookingId) + const realContainerNo = String(container?.containerNo || '') + context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse, + id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo + && realContainerNo.length > 0 && positiveId(oceanBookingId) + && Number(container?.version) > Number(fixture.beforeContainerVersion) + && id(container?.warehouseId) === id(fixture.warehouseId) && container?.channelCode === fixture.channelCode, + `containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${realContainerNo || 'missing'}, oceanBookingId=${oceanBookingId || 'missing'}`, + OPTIONS) + if (!positiveId(oceanBookingId) || !realContainerNo) return + + const bookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`) + const booking = context.apiSuccess(MODULE, '读取关联后的 RELEASED 订舱与 allocation', bookingResponse, '', OPTIONS) + const allocation = rows(booking?.containers).find((item) => id(item.containerBookingId) === containerId + && item.containerNo === realContainerNo) + context.assert(MODULE, '订舱已放舱且唯一 allocation 指向原柜次', bookingResponse, + booking?.oceanBooking?.status === 'RELEASED' && positiveId(allocation?.requirementId) + && positiveId(allocation?.allocationId) + && rows(booking?.containers).filter((item) => id(item.containerBookingId) === containerId).length === 1, + `status=${booking?.oceanBooking?.status || 'missing'}, bookingNo=${booking?.oceanBooking?.bookingNo || 'missing'}, requirementId=${id(allocation?.requirementId) || 'missing'}, allocationId=${id(allocation?.allocationId) || 'missing'}`, + OPTIONS) + + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const detail = context.apiSuccess(MODULE, '读取整份预装确认结果', detailResponse, '', OPTIONS) + const taskId = id(detail?.summary?.readiness?.pdaTaskId) + context.assert(MODULE, '预装确认状态为 CONFIRMED 并返回唯一 PDA taskId', detailResponse, + id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING' + && detail?.summary?.readiness?.confirmationStatus === 'CONFIRMED' + && detail?.summary?.readiness?.canConfirm === false + && detail?.summary?.readiness?.readyForPda === true && positiveId(taskId), + `confirmationStatus=${detail?.summary?.readiness?.confirmationStatus || 'missing'}, readyForPda=${detail?.summary?.readiness?.readyForPda}, taskId=${taskId || 'missing'}`, + OPTIONS) + if (!positiveId(taskId)) return + + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, '读取唯一待领取 PDA 任务', taskResponse, '', OPTIONS) + const plannedPackages = rows(task?.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0) + const expectedPackages = rows(fixture.expectedAssignments) + .reduce((sum, item) => sum + Number(item.expectedAllocatedPackageCount || 0), 0) + context.assert(MODULE, 'PDA 任务四元身份、未领取状态和计划箱数正确', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === planId + && id(task?.containerBookingId) === containerId && task?.cabinetNo === fixture.cabinetNo + && task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING' + && task?.workflowStatus === 'UNASSIGNED' && task?.assignment == null + && rows(task?.items).length > 0 && plannedPackages === expectedPackages, + `taskId=${id(task?.id) || 'missing'}, taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, plannedPackages=${plannedPackages}, expectedPackages=${expectedPackages}`, + OPTIONS) + + for (const assignment of fixture.expectedAssignments || []) { + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(assignment.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`) + const inbound = context.apiSuccess(MODULE, `${assignment.role} claim 前回查入库状态`, inboundResponse, '', OPTIONS) + context.assert(MODULE, `${assignment.role} 在 PDA 领取前仍为 WAIT_SPLIT`, inboundResponse, + id(inbound?.summary?.id) === id(assignment.inboundId) + && inbound?.summary?.processStatus === 'WAIT_SPLIT', + `inboundId=${id(inbound?.summary?.id) || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`, + OPTIONS) + } + + context.createdResources.manualPdaReadyVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_UNASSIGNED', + oceanBookingId, bookingNo: booking?.oceanBooking?.bookingNo || null, + requirementId: id(allocation?.requirementId), allocationId: id(allocation?.allocationId), + loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo, + containerNo: realContainerNo, pdaTaskId: taskId, tenantId: id(fixture.tenantId), + warehouseId: id(fixture.warehouseId), plannedPackageCount: plannedPackages, + nextStage: 'PDA 人工领取任务后进入 LOADING,再执行扫码、批量提交和待复核验收。', + } +} diff --git a/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs b/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs new file mode 100644 index 000000000..0c4d507cc --- /dev/null +++ b/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-preload-push-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-preload-push-verify.mjs', import.meta.url), 'utf8') + +test('manual preload modules are optional and require an explicit source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-preload-push-fixture'[^\]]*'manual-preload-push-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-preload-push-fixture'), false) + assert.equal(defaults.includes('manual-preload-push-verify'), false) + for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }), + /manual-preload-source-report/) + } +}) + +test('preload preparation is read only and freezes the pre-push plan snapshot', () => { + assert.match(fixture, /manualOrderSplittingVerification/) + assert.match(fixture, /beforePlanVersion/) + assert.match(fixture, /beforeItems/) + assert.match(fixture, /未点击推送前不存在预装 bridge/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('post-push verifier proves EDITING bridge visibility and PDA A0404 without writes', () => { + assert.match(verify, /Number\(plan\?\.version\) > Number\(fixture\.beforePlanVersion\)/) + assert.match(verify, /allocatedPackageCount/) + assert.match(verify, /preloadStatus === 'PENDING'/) + assert.match(verify, /readiness\?\.canConfirm === false/) + assert.match(verify, /codeOf\(taskResponse\) === 'A0404'/) + assert.match(verify, /container\?\.containerNo == null/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-preload-push-fixture.mjs b/scripts/admin-test-env-modules/manual-preload-push-fixture.mjs new file mode 100644 index 000000000..a702e30b9 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-preload-push-fixture.mjs @@ -0,0 +1,70 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-preload-push-fixture' +const OPTIONS = { scenario: 'manual-preload-push-fixture', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function itemSnapshot(items) { + return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8')) + const verification = report?.createdResources?.manualOrderSplittingVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && verification?.loadingPlanId + && verification?.containerBookingId && Array.isArray(verification?.verifiedAssignments) + context.assert(MODULE, '读取同环境人工分柜核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(verification?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? verification : null +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const planId = id(source.loadingPlanId) + const containerId = id(source.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '推送前读取智能装柜计划', planResponse, '', OPTIONS) + const items = itemSnapshot(plan?.items) + context.assert(MODULE, '计划仍处于未推送 EDITING 且分柜明细完整', planResponse, + id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId + && plan?.status === 'EDITING' && Number.isInteger(Number(plan?.version)) && items.length > 0, + `planId=${id(plan?.id) || 'missing'}, status=${plan?.status || 'missing'}, version=${plan?.version ?? 'missing'}, items=${items.length}`, + OPTIONS) + if (!plan || plan.status !== 'EDITING' || items.length === 0) return + + const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`) + const links = context.apiSuccess(MODULE, '推送前回查尚未建立预装 bridge', linksResponse, '', OPTIONS) + context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse, + rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS) + context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse, + id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null + && id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode, + `containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`, + OPTIONS) + + context.createdResources.manualPreloadPushFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_PUSH_PRELOAD_READY', loadingPlanId: planId, containerBookingId: containerId, + cabinetNo: container?.cabinetNo || null, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId), + channelCode: source.channelCode, beforePlanVersion: Number(plan.version), beforeItems: items, + expectedAssignments: source.verifiedAssignments, + instructions: [ + '在智能装柜计划页面点击推送预装。', + '本节点保持无真实集装箱号、无订舱关联,不执行计划确认。', + '推送成功后不要继续录入柜号或创建 PDA 任务,立即运行 manual-preload-push-verify。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-preload-push-verify.mjs b/scripts/admin-test-env-modules/manual-preload-push-verify.mjs new file mode 100644 index 000000000..e89d8834e --- /dev/null +++ b/scripts/admin-test-env-modules/manual-preload-push-verify.mjs @@ -0,0 +1,106 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-preload-push-verify' +const OPTIONS = { scenario: 'manual-preload-push-readonly-verification', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const CONTAINERS = '/api/platform/v1/containers' +const PDA = `${FIRST_LEG}/pda-loading` + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function itemSnapshot(items) { + return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualPreloadPushFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_PUSH_PRELOAD_READY' && fixture?.loadingPlanId + && fixture?.containerBookingId && Array.isArray(fixture?.beforeItems) + context.assert(MODULE, '读取同环境推送预装准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const planId = id(fixture.loadingPlanId) + const containerId = id(fixture.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '读取人工推送后的智能装柜计划', planResponse, '', OPTIONS) + const afterItems = itemSnapshot(plan?.items) + context.assert(MODULE, '无号推送后计划保持 EDITING、版本递增且明细零漂移', planResponse, + id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId + && plan?.status === 'EDITING' && plan?.containerNo == null + && Number(plan?.version) > Number(fixture.beforePlanVersion) + && JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems), + `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`, + OPTIONS) + if (!plan || plan.status !== 'EDITING') return + + const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`) + const links = context.apiSuccess(MODULE, '回查推送原子建立的预装 bridge', linksResponse, '', OPTIONS) + const linkRows = rows(links) + for (const expected of fixture.expectedAssignments || []) { + const matched = linkRows.filter((link) => id(link.inboundId) === id(expected.inboundId)) + const allocated = matched.reduce((sum, link) => sum + Number(link.allocatedPackageCount || 0), 0) + context.assert(MODULE, `${expected.role} 预装 bridge 数量与人工分柜一致`, linksResponse, + matched.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount) + && matched.every((link) => id(link.loadingPlanId) === planId + && id(link.containerBookingId) === containerId && link.preloadStatus === 'PENDING' + && link.loadingPlanStatus === 'EDITING' && Number(link.loadingPlanVersion) === Number(plan.version)), + `inboundId=${id(expected.inboundId)}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, links=${matched.length}`, + OPTIONS) + } + const expectedInboundIds = new Set((fixture.expectedAssignments || []).map((item) => id(item.inboundId))) + const unexpectedLinks = linkRows.filter((link) => !expectedInboundIds.has(id(link.inboundId))) + context.assert(MODULE, '预装 bridge 未混入人工分柜清单外入库单', linksResponse, + unexpectedLinks.length === 0 && linkRows.length >= expectedInboundIds.size, + `linkCount=${linkRows.length}, unexpectedInboundIds=${unexpectedLinks.map((link) => id(link.inboundId)).join(',') || 'none'}`, + OPTIONS) + + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const detail = context.apiSuccess(MODULE, '读取推送后正式预装清单详情', detailResponse, '', OPTIONS) + context.assert(MODULE, '预装清单保持 EDITING 且无号阶段不能确认或创建 PDA', detailResponse, + id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING' + && detail?.summary?.readiness?.canConfirm === false + && detail?.summary?.readiness?.readyForPda === false + && detail?.summary?.readiness?.pdaTaskId == null, + `planId=${id(detail?.summary?.planId) || 'missing'}, status=${detail?.summary?.status || 'missing'}, canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}`, + OPTIONS) + + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + context.addStep(MODULE, '无真实柜号推送后 PDA 查询仍为 A0404', taskResponse, + taskResponse.http === 200 && codeOf(taskResponse) === 'A0404', + `HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`, + OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS) + context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse, + id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo + && container?.containerNo == null && container?.oceanBookingId == null, + `containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`, + OPTIONS) + + context.createdResources.manualPreloadPushVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PRELOAD_EDITING', + loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo, + tenantId: id(fixture.tenantId), warehouseId: id(fixture.warehouseId), channelCode: fixture.channelCode, + planVersion: Number(plan.version), preloadLinkCount: linkRows.length, pdaTaskCreated: false, + expectedAssignments: fixture.expectedAssignments, + nextStage: '关联 RELEASED 订舱与真实柜号后,人工执行整份预装计划确认。', + } +} diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md index 2e52faa80..26f2c6e41 100644 --- a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -7,7 +7,7 @@ - 用于开发、联调或经明确授权的验收环境准备人工操作数据。 - 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。 - 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。 -- 不负责后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货。 +- 基础批量模式不自动执行后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货;需要人工分柜验收时使用本文后半部分的准备与只读核验入口。 - 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。 ## 固定项与随机项 @@ -121,6 +121,286 @@ node scripts/admin-test-env-acceptance.mjs ` 报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。 +## 人工分柜验收 + +### 1. 生成分柜准备清单 + +人工分柜模块默认生成 6 票同客户、同起运仓、同渠道的 `WAIT_SPLIT` 数据,并在报告的 `createdResources.manualOrderSplittingFixture` 中给出: + +- 一票 `PARTIAL`:只分配部分箱数,固定保留 1 箱; +- 两票 `FULL_1/FULL_2`:整票加入同一个柜,验证多订单拼柜; +- 一票 `UNASSIGNED_CONTROL`:不得加入计划,用于核对未分配对照和候选余量; +- 全部订单、入库单、入仓码、箱数、客户、仓库和渠道信息。 + +复用既有客户时执行: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-order-splitting-fixture ` + --merchant-username $env:MERCHANT_TEST_USERNAME ` + --merchant-password $env:MERCHANT_TEST_PASSWORD ` + --origin-warehouse-name '<固定国内仓名称>' ` + --channel-name '<固定渠道名称>' ` + --channel-code '<固定渠道编码>' ` + --order-profile-mode fixed ` + --wait-split-count 6 ` + --output '<人工分柜准备报告绝对路径>.json' +``` + +`manual-order-splitting-fixture` 会自动展开 `orders → inbound-measurement`。本入口通过正式 HTTP 接口写入测试数据,仍需当前对话明确授权目标环境。 + +### 2. Admin 人工操作 + +根据准备报告执行: + +1. 打开智能装柜页面,创建与清单一致的起运仓、渠道模拟柜。 +2. 创建智能装柜计划。 +3. 严格按 `assignments[].expectedAllocatedPackageCount` 分配三票入仓码。 +4. 不要选择 `unassignedControl`。 +5. 保存计划但暂不推送预装,记录页面或接口返回的 `loadingPlanId`。 + +此阶段只是“计划分柜”。父子订单和最终实装数量要到 PDA 实装结算后形成,不能在本节点提前宣称已经完成订单拆分。 + +### 3. 人工操作后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-order-splitting-verify ` + --manual-split-fixture-report '<人工分柜准备报告绝对路径>.json' ` + --manual-split-plan-id '<页面记录的 loadingPlanId>' ` + --output '<人工分柜核验报告绝对路径>.json' +``` + +核验模块只发送 GET 请求,检查: + +- 计划与柜次 ID 有效,计划仍处于可继续验收的 `EDITING/LOADING`; +- 柜次仓库、渠道与准备清单一致; +- `PARTIAL/FULL_1/FULL_2` 的计划箱数精确匹配; +- 未混入清单外入库单,`UNASSIGNED_CONTROL` 未被误选; +- 候选回查不出现异渠道或异仓入库单; +- 每票分配后的候选剩余箱数满足数量守恒。 + +核验报告会写入 `createdResources.manualOrderSplittingVerification`,记录来源 marker、计划、柜次和已核验分配,不保存凭据。 + +## 人工推送预装验收 + +完成上一节人工分柜核验后,继续验证“点击推送预装,但尚无真实柜号、未关联订舱”的节点。 + +### 1. 生成推送前操作清单 + +使用上一节生成的“人工分柜核验报告”: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-preload-push-fixture ` + --manual-preload-source-report '<人工分柜核验报告绝对路径>.json' ` + --output '<推送预装前准备报告绝对路径>.json' +``` + +该模块只发送 GET 请求,并确认: + +- 智能装柜计划仍为 `EDITING`; +- 人工分柜明细存在且版本有效; +- 尚未建立预装 bridge; +- 柜次仍为无真实 `containerNo`、无 `oceanBookingId` 的内部模拟柜。 + +成功后,报告中的 `createdResources.manualPreloadPushFixture` 会保存推送前计划版本、明细快照和操作说明。 + +### 2. Admin 人工推送 + +在同一智能装柜计划上点击“推送预装”。本节点只做这一项操作: + +- 不录入真实集装箱号; +- 不创建或关联订舱; +- 不确认预装计划; +- 不领取或创建 PDA 任务。 + +推送成功后立即执行下一步只读核验。 + +### 3. 推送后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-preload-push-verify ` + --manual-preload-source-report '<推送预装前准备报告绝对路径>.json' ` + --output '<推送预装核验报告绝对路径>.json' +``` + +核验内容包括: + +- 计划仍为 `EDITING`,版本相对推送前递增,分柜明细没有漂移; +- 每票入库单生成 `PENDING` 预装 bridge,箱数与人工分柜一致; +- 正式预装清单详情可见; +- 无真实柜号阶段 `canConfirm=false`、`readyForPda=false`; +- PDA 任务查询返回 `A0404`; +- 推送没有伪造真实柜号、订舱关联或改变稳定柜次身份。 + +通过后报告写入 `createdResources.manualPreloadPushVerification`,下一节点再处理订舱放舱、真实柜号关联和整份预装计划确认。 + +## 人工订舱关联、预装确认与 PDA 待领取验收 + +### 1. 冻结操作前基线 + +以上一节“推送预装核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-ready-fixture ` + --manual-pda-source-report '<推送预装核验报告绝对路径>.json' ` + --output '.json' +``` + +准备模块只发送 GET 请求,确认: + +- 已推送计划仍为 `EDITING`,版本和分柜明细没有变化; +- 原柜次仍无真实柜号、无订舱关联; +- 当前 `canConfirm=false`、`readyForPda=false`; +- PDA 查询仍返回 `A0404`。 + +报告的 `createdResources.manualPdaReadyFixture` 保存柜次、计划、版本和明细基线。 + +### 2. Admin 人工操作 + +严格按以下顺序操作: + +1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐提单号、船名、航次、ETD、ETA、参与方和柜型需求。 +2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。 +3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。 +4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。 +5. 确认成功后不要领取 PDA 任务,记录订舱号和真实柜号并立即执行核验。 + +### 3. 只读核验 PDA 待领取状态 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-ready-verify ` + --manual-pda-source-report '.json' ` + --output '.json' +``` + +核验内容包括: + +- 订舱为 `RELEASED`,且 allocation 唯一指向原 `containerBookingId`; +- 真实柜号在原柜次上补全,`cabinetNo/loadingPlanId/items` 不漂移; +- 整份预装为 `CONFIRMED`、`readyForPda=true`,并返回唯一 `pdaTaskId`; +- PDA task 四元身份正确,`taskStatus=LOADING`、`workflowStatus=UNASSIGNED`; +- PDA 计划箱数与人工分柜总箱数一致; +- 尚未领取任务时各来源入库单仍保持 `WAIT_SPLIT`。 + +通过后报告写入 `createdResources.manualPdaReadyVerification`。下一节点进入 PDA 人工领取、扫码、批量提交和待复核验收。 + +## PDA 人工领取、扫码和批量提交验收 + +### 1. 冻结 PDA 作业前基线 + +以上一节“PDA 待领取核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-batch-fixture ` + --manual-pda-batch-source-report '.json' ` + --output '.json' +``` + +准备模块只发送 GET 请求,确认并记录: + +- 任务为 `UNASSIGNED`,尚未绑定作业员; +- PDA 待办中只有同一稳定 `taskId`; +- 每条任务明细包含稳定 `boxCodes`,数量足以覆盖计划箱数; +- 库存快照的任务版本和四元身份一致; +- 操作历史尚无 `CLAIM/BATCH/REVIEW/COMPLETE_LOADING`。 + +报告的 `createdResources.manualPdaBatchFixture.taskItems[].scanBoxCodes` 提供本轮应扫描箱码,`boxCodes` 保留服务端完整下发集合;库存 `snapshotToken` 不写入报告,PDA 提交前必须实时重新获取。 + +### 2. PDA 真机人工操作 + +1. 使用目标仓库作业员进入 PDA 待办并领取任务,确认页面进入作业中。 +2. 按准备报告对应任务明细,在真机逐箱扫描稳定箱码;同一箱码不能重复扫描。 +3. 所有明细均按计划箱数完成,本节点不使用 `PARTIAL` 或 `SKIP`。 +4. 点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch。 +5. 成功后停在 `AWAITING_REVIEW`,不要上传五类证据,不要复核或执行最终完成。 + +服务端 batch 只接收明细与数量,不保存逐次扫码轨迹。因此自动报告只能证明稳定箱码已下发和服务端数量已收敛,真机逐箱扫码过程必须保留人工证据。 + +### 3. 批量提交后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-batch-verify ` + --manual-pda-batch-source-report '.json' ` + --output '.json' +``` + +核验内容包括: + +- 任务已绑定真实作业员,版本递增; +- 所有明细均为 `PICKED`,已拣箱数等于计划箱数; +- 任务保持 `taskStatus=LOADING`,工作流进入 `AWAITING_REVIEW`; +- pending 与 detail 均显示零条未处理明细; +- 操作历史恰有一次 `CLAIM`、一次 `BATCH`,没有 `REVIEW/COMPLETE_LOADING`; +- 各入库单服务端汇总 `expectedCount=pickedCount`; +- 待复核取证 GET 已开放。 + +通过后报告写入 `createdResources.manualPdaBatchVerification`,并明确标记逐次扫码仍需真机证据。下一节点处理五类装柜证据和 PDA 最终完成。 + +## 五类装柜证据、PDA 最终完成与 LOADED 验收 + +### 1. 冻结最终完成前基线 + +以上一节“PDA 待复核核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-loading-complete-fixture ` + --manual-loading-complete-source-report '.json' ` + --output '<装柜最终完成前准备报告绝对路径>.json' +``` + +准备模块只发送 GET 请求,确认: + +- PDA 任务为 `AWAITING_REVIEW`,所有明细已拣齐; +- 装柜计划仍为 `LOADING/IN_PROGRESS`; +- 任务审计只有一次 `CLAIM` 和一次 `BATCH`; +- 当前证据归属入库单没有 ACTIVE 装柜证据; +- 真实柜号和 `RELEASED` 订舱门禁仍有效。 + +报告的 `createdResources.manualLoadingCompleteFixture` 会固定证据归属、五类证据要求和最终完成前版本。 + +### 2. PDA 真机人工操作 + +在同一 PDA 任务内现场完成: + +1. 拍摄空仓照 `PHOTO_STEP_1`。 +2. 拍摄半仓照 `PHOTO_STEP_2`。 +3. 拍摄满仓照 `PHOTO_STEP_3`。 +4. 拍摄锁仓照 `PHOTO_STEP_4`。 +5. 拍摄带锁码照 `SEAL`,并填写非空锁码。 +6. 确认五类均为 ACTIVE 后,只点击一次最终完成。 + +不要调用旧入库单级兼容完成入口;当前唯一正式动作是 PDA task 的最终完成。成功后不要继续修改柜号、计划或证据。 + +### 3. LOADED 终态只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-loading-complete-verify ` + --manual-loading-complete-source-report '<装柜最终完成前准备报告绝对路径>.json' ` + --output '.json' +``` + +核验内容包括: + +- 五类 ACTIVE 证据各一份,SEAL 锁码非空且每张图片可鉴权下载; +- 任务 `taskStatus/workflowStatus` 均为 `COMPLETED`; +- 操作历史恰有一次 `COMPLETE_LOADING`,并保留原 CLAIM/BATCH; +- 原装柜计划进入 `COMPLETED`; +- 所有来源入库单进入 `LOADED`,状态历史包含 `LOADING → LOADED`; +- 对应统一订单详情投影为 `LOADED`; +- 柜次实装分片唯一,实装箱数等于 PDA 已拣总箱数; +- 货柜总览聚合 COMPLETED PDA 和五类 ACTIVE 证据。 + +通过后报告写入 `createdResources.manualLoadingCompleteVerification`。至此,本指南覆盖的国内人工验收链已经从订单造数连续到装柜 `LOADED`;后续报关、国内出仓和海外接柜另按对应节点扩展。 + ## 失败、重跑与批量拆分 - 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。 @@ -148,7 +428,17 @@ node scripts/admin-test-env-acceptance.mjs ` ```powershell node --check scripts/admin-test-env-acceptance.mjs node --check scripts/admin-test-env-modules/orders.mjs -node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs +node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs +node --check scripts/admin-test-env-modules/manual-preload-push-fixture.mjs +node --check scripts/admin-test-env-modules/manual-preload-push-verify.mjs +node --check scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs +node --check scripts/admin-test-env-modules/manual-pda-ready-verify.mjs +node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs +node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs +node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs +node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs +node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs git diff --check ``` From 795e852944160faaa6f089bbc92031fa83233a3f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:09:02 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReceivingContainerController.java | 8 - .../pda/dto/ReceivingContainerRequests.java | 4 +- .../pda/dto/ReceivingContainerResponses.java | 9 +- .../service/ReceivingContainerService.java | 204 ++++++++++-------- .../openapi/overseas-business-v1.yaml | 31 +-- .../pda/ReceivingContainerContractTest.java | 10 +- ...ReceivingContainerServiceBehaviorTest.java | 63 +++++- scripts/admin-test-env-acceptance.mjs | 12 +- .../manual-pda-ready-fixture.mjs | 2 +- .../manual-state-loading-pda.mjs | 2 +- ...nt-qualification-fixture-contract.test.mjs | 3 + .../ocean-booking-fixture-contract.test.mjs | 1 + .../ocean-booking-fixture.mjs | 1 + .../phase3-container-loading.mjs | 2 +- .../admin-test-env-modules/phase3-loading.mjs | 2 +- .../批量待分柜数据生成指南.md | 2 +- 16 files changed, 201 insertions(+), 155 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index d614409c4..f5aa7e7d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -112,14 +112,6 @@ public class ReceivingContainerController { return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } - @PostMapping("/current/skus/resolve") - public ApiResponse resolveSku( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - PdaWarehouseIdentity identity = currentIdentity(user); - return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); - } - @PostMapping("/current/pallets/resolve") public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 1b02cf639..6d06a96c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -18,10 +18,8 @@ public final class ReceivingContainerRequests { public record SignedPhoto(@NotNull ReceivingPhotoRole role, @NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } - public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { } public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } - public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode, - @NotBlank String palletCode, + public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, @NotBlank @Size(max = 255) String idempotencyKey) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 23a3be0bd..6afe67cba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -28,20 +28,13 @@ public final class ReceivingContainerResponses { String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } - public record CartonResolved(String cartonMark, String skuCode, String inboundCode, + public record CartonResolved(String cartonMark, String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } - public record SkuResolved(String skuCode, String productName, String inboundCode, - String destinationType, String overseasWarehouseId, - String overseasWarehouseName, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - String fbaWarehouseCode, int plannedPalletCount, int totalPackages, - int receivedPackages, int pendingPackages) { } - public record PalletResolved(String palletCode, long warehouseId, String status, String fbaWarehouseCode, String inboundCode, String destinationType, boolean compatible) { } 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 5aa3b65e0..d9c2e539f 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 @@ -226,42 +226,16 @@ public class ReceivingContainerService { ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); - List currentRefs = refs(customerId, session.getContainerNo()); - if (currentRefs.stream().noneMatch(value -> - "OVERSEAS".equals(destinationType(value.line(), value.order())))) { - throw conflict("FBA destination must use bulk count"); - } - InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() - .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); - if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = currentRefs.stream() - .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() - .orElseThrow(() -> conflict("Carton mark belongs to another container")); - if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { - throw conflict("FBA inbound must use bulk count instead of carton scan"); - } + CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + InboundRef ref = carton.ref(); ReceivingContainerResponses.Inbound inbound = inbound(ref); - return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), + return new ReceivingContainerResponses.CartonResolved(carton.carton().getCartonMark(), inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } - public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey, - ReceivingContainerRequests.SkuResolve request) { - ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - List matches = refs(customerId, session.getContainerNo()).stream() - .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) - .map(ref -> skuRef(ref, request.skuCode())) - .filter(Objects::nonNull) - .toList(); - if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container"); - if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container"); - return skuResponse(matches.get(0)); - } - public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); @@ -284,22 +258,27 @@ public class ReceivingContainerService { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } - SkuRef sku = requireSku(ref, request.skuCode()); - validateSkuPending(customerId, sku, request.packageCount()); - workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); + List fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref); + List skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); + skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), + allocation.ref().line(), allocation.packages())); WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { - int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, - request.packageCount(), operatorKey, operatorName); - recordSkuReceipt(session, sku, pallet, accepted); + int accepted = 0; + for (FbaSkuAllocation allocation : skuAllocations) { + accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet, + allocation.packages(), operatorKey, operatorName); + recordSkuReceipt(session, allocation, pallet); + } markReceiving(session); - ReceivingContainerResponses.Inbound inbound = inbound(ref); + int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(); + int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum(); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", - session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted, + received, Math.max(0, total - received), session.getStatus()); save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; } catch (RuntimeException failure) { @@ -320,12 +299,8 @@ public class ReceivingContainerService { "OVERSEAS".equals(destinationType(value.line(), value.order())))) { throw conflict("FBA destination must use bulk count"); } - InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() - .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); - if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = currentRefs.stream() - .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() - .orElseThrow(() -> conflict("Carton mark belongs to another container")); + CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + InboundRef ref = carton.ref(); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { throw conflict("FBA destination must use bulk count"); } @@ -426,7 +401,8 @@ public class ReceivingContainerService { List result = new ArrayList<>(); for (InboundPlan plan : plansForContainer(customerId, containerNo)) { for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() - .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .orderByAsc(InboundPlanLine::getId))) { LastlegOrder order = orders.selectById(line.getOrderId()); if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); result.add(new InboundRef(plan, line, order)); @@ -439,8 +415,37 @@ public class ReceivingContainerService { List matches = refs(customerId, containerNo).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); - if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); - return matches.get(0); + Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); + if (planIds.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); + return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow(); + } + + 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"); + 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)); + } + + private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { + List values = refs(customerId, containerNo).stream() + .filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId())) + .sorted(Comparator.comparing(value -> value.line().getId())).toList(); + if (values.isEmpty() || values.stream().anyMatch(value -> + !"FBA".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA"); + } + Set destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode())) + .collect(Collectors.toSet()); + if (destinations.size() != 1 || destinations.contains(null)) { + throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan"); + } + return values; } private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { @@ -553,44 +558,54 @@ public class ReceivingContainerService { throw conflict("Unload photo file ID must be a positive decimal string"); } - private SkuRef requireSku(InboundRef ref, String skuCode) { - SkuRef result = skuRef(ref, skuCode); - if (result == null) throw notFound("SKU is not part of the selected inbound code"); + private List allocateFbaSkuReceipts(String customerId, List refs, int packageCount) { + int remaining = packageCount; + List result = new ArrayList<>(); + for (InboundRef ref : refs) { + int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref); + List expectations = skuExpectations(ref); + int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages() + - receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode())).sum(); + if (linePending < 0 || skuPending != linePending) { + throw conflict("FBA SKU receipt progress must exactly match the inbound line progress"); + } + for (SkuExpectation expectation : expectations) { + if (remaining == 0) break; + int skuPendingPackages = expectation.expectedPackages() + - receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode()); + int accepted = Math.min(remaining, skuPendingPackages); + if (accepted > 0) { + result.add(new FbaSkuAllocation(ref, expectation.skuCode(), expectation.productName(), accepted)); + remaining -= accepted; + } + } + if (remaining == 0) break; + } + if (remaining != 0) throw conflict("Received package count exceeds the FBA inbound pending total"); return result; } - private SkuRef skuRef(InboundRef ref, String skuCode) { - String requested = trim(skuCode); - if (requested == null) return null; + private List skuExpectations(InboundRef ref) { List details = orderDetails.selectList(new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); - List matched = details.stream() - .filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList(); - int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum(); - if (expected <= 0) return null; - String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim) - .filter(Objects::nonNull).findFirst().orElse(requested); - String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn())) - .filter(Objects::nonNull).findFirst().orElse(null); - return new SkuRef(ref, canonicalSku, productName, expected); - } - - private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) { - ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); - int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); - return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), - inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), - inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), - inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), - sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); - } - - private void validateSkuPending(String customerId, SkuRef sku, int packageCount) { - int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode()); - if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) { - throw conflict("Received package count exceeds the SKU pending total"); + Map grouped = new LinkedHashMap<>(); + for (LastlegOrderDetail detail : details) { + String skuCode = trim(detail.getSkuCode()); + int expected = value(detail.getPackageCount()); + if (skuCode == null || expected <= 0) continue; + String key = skuCode.toUpperCase(java.util.Locale.ROOT); + SkuExpectation existing = grouped.get(key); + grouped.put(key, existing == null ? new SkuExpectation(skuCode, + first(detail.getProductNameZh(), detail.getProductNameEn()), expected) + : new SkuExpectation(existing.skuCode(), existing.productName(), existing.expectedPackages() + expected)); } + List result = List.copyOf(grouped.values()); + int expected = result.stream().mapToInt(SkuExpectation::expectedPackages).sum(); + if (result.isEmpty() || expected != value(ref.line().getRequiredPackageCount())) { + throw conflict("Frozen FBA SKU package snapshots must exactly match the inbound line total"); + } + return result; } private int receivedSkuPackages(String customerId, Long lineId, String skuCode) { @@ -598,7 +613,15 @@ public class ReceivingContainerService { .mapToInt(value -> value(value.getReceivedPackages())).sum(); } - private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) { + private int receivedLinePackages(InboundRef ref) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + } + + private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) { List existing = skuReceipts.lockLineSku(session.getCustomerId(), sku.ref().line().getId(), sku.skuCode()); ReceivingSkuReceipt receipt = existing.stream() @@ -610,11 +633,11 @@ public class ReceivingContainerService { receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); - receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages); + receipt.setProductName(sku.productName()); receipt.setReceivedPackages(sku.packages()); receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0); skuReceipts.insert(receipt); } else { - receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages); + receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + sku.packages()); receipt.setUpdatedAt(now); if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); } @@ -623,21 +646,14 @@ public class ReceivingContainerService { private void validateFbaSkuBalance(String customerId, String containerNo) { for (InboundRef ref : refs(customerId, containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; - List details = orderDetails.selectList(new LambdaQueryWrapper() - .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); - Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) - .map(ReceivingContainerService::trim).filter(Objects::nonNull) - .map(value -> value.toUpperCase(java.util.Locale.ROOT)) - .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); - int expected = 0; + List skuCodes = skuExpectations(ref); int received = 0; - for (String skuCode : skuCodes) { - SkuRef sku = requireSku(ref, skuCode); + for (SkuExpectation sku : skuCodes) { int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode()); if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container"); - expected += sku.expectedPackages(); received += skuReceived; + received += skuReceived; } - if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) { + if (received != value(ref.line().getRequiredPackageCount())) { throw conflict("FBA SKU package snapshots must exactly match the inbound code total"); } } @@ -790,7 +806,9 @@ 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 SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { } + private record CartonRef(InboundPlanCarton carton, 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, int total, int received) { } private record PrepareCommand(String containerNo, String idempotencyKey) { } 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 c059d4b88..d39ab278b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -72,17 +72,10 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: post: summary: 解析当前柜箱唛 - description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 + description: 权限 lastleg:pda:operate;FBA 和 OVERSEAS 均可解析当前锁定柜内箱码,返回唯一入仓码、目的类型、目标仓和实收进度;不返回 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: 跨柜、重复箱或目的类型不符 } } - /api/pda/v1/last-leg/receiving-containers/current/skus/resolve: - post: - summary: 解析当前柜 FBA SKU - description: 权限 lastleg:pda:operate;仅解析当前已签柜内唯一归属一个入仓码的 FBA SKU,返回冻结箱数与实收进度。 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, minLength: 1, maxLength: 128 } } } } } } - responses: { '200': { description: SKU与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSkuResolvedApiResponse' } } } }, '404': { description: A0404:SKU不在当前FBA柜内 }, '409': { description: A0409:SKU跨多个入仓码导致归属不唯一 } } + responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 箱码跨柜或归属不唯一 } } /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: post: summary: 解析当前柜实物托盘码 @@ -93,7 +86,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: post: summary: FBA 入仓码按箱数收货 - description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。 + description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } } @@ -658,10 +651,9 @@ components: downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 } ReceivingBulk: type: object - required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] + required: [inboundCode, palletCode, packageCount, idempotencyKey] properties: inboundCode: { type: string, minLength: 1 } - skuCode: { type: string, minLength: 1, maxLength: 128 } palletCode: { type: string, minLength: 1 } packageCount: { type: integer, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } @@ -751,15 +743,6 @@ components: required: [cartonMark] properties: cartonMark: { type: string } - skuCode: { type: string, nullable: true } - ReceivingSkuResolved: - allOf: - - { $ref: '#/components/schemas/ReceivingInbound' } - - type: object - required: [skuCode] - properties: - skuCode: { type: string } - productName: { type: string, nullable: true } ReceivingPalletResolved: type: object required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] @@ -814,12 +797,6 @@ components: - type: object required: [data] properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } } - ReceivingSkuResolvedApiResponse: - allOf: - - { $ref: '#/components/schemas/ReceivingApiResponseBase' } - - type: object - required: [data] - properties: { data: { $ref: '#/components/schemas/ReceivingSkuResolved' } } ReceivingPalletResolvedApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 2ac0fd471..65b4ae086 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -126,7 +126,7 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains(prefix + "/candidates:")); assertTrue(yaml.contains(prefix + "/warehouse-profile:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); - assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); + assertFalse(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); @@ -134,8 +134,8 @@ class ReceivingContainerContractTest { String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(17, successResponses.size()); - assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(16, successResponses.size()); + assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); @@ -146,6 +146,10 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains("overseasWarehouseId: { type: string")); assertTrue(yaml.contains("overseasWarehouseName: { type: string")); assertFalse(receivingPaths.contains("photoFileIds")); + assertFalse(receivingPaths.contains("skuCode")); + assertFalse(yaml.contains("ReceivingSkuResolved")); + assertFalse(Arrays.stream(ReceivingContainerRequests.class.getDeclaredClasses()) + .anyMatch(type -> "SkuResolve".equals(type.getSimpleName()))); } private String resource(String name) throws IOException { 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 b7bf58732..1362e3053 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 @@ -182,7 +182,7 @@ class ReceivingContainerServiceBehaviorTest { void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -209,7 +209,7 @@ class ReceivingContainerServiceBehaviorTest { stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); } @@ -223,7 +223,7 @@ class ReceivingContainerServiceBehaviorTest { doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 2, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -291,7 +291,7 @@ class ReceivingContainerServiceBehaviorTest { doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "compensate-key"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); } @@ -391,7 +391,7 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { + void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() { stubCurrent("FBA", "CONT-A"); stubSku(); com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received = @@ -400,11 +400,62 @@ class ReceivingContainerServiceBehaviorTest { when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received)); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); } + @Test + void cartonResolutionSupportsFbaWithoutProjectingSku() { + stubCurrent("FBA", "CONT-A"); + when(cartons.selectOne(any())).thenReturn(carton(1L, "FBA-BOX-1")); + + var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("FBA-BOX-1")); + + assertThat(resolved.cartonMark()).isEqualTo("FBA-BOX-1"); + assertThat(resolved.inboundCode()).isEqualTo("IN-1"); + assertThat(resolved.destinationType()).isEqualTo("FBA"); + } + + @Test + void fbaBulkDistributesAcrossLinesAndFrozenSkusInStableLineOrder() { + InboundPlan plan = plan("CONT-A"); + InboundPlanLine first = line("FBA"); + InboundPlanLine second = line("FBA"); + second.setId(20L); second.setOrderId(2L); second.setRequiredPackageCount(2); + LastlegOrder firstOrder = order("FBA"); + LastlegOrder secondOrder = order("FBA"); secondOrder.setId(2L); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "SIGNED")); + when(plans.selectList(any())).thenReturn(List.of(plan)); + when(lines.selectList(any())).thenReturn(List.of(second, first)); + when(orders.selectById(1L)).thenReturn(firstOrder); + when(orders.selectById(2L)).thenReturn(secondOrder); + when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)), + List.of(detail("SKU-C", 2))); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); + when(pallets.selectList(any())).thenReturn(List.of(planPallet())); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenAnswer(invocation -> invocation.getArgument(4)); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var result = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "split-key")); + + assertThat(result.acceptedPackages()).isEqualTo(3); + ArgumentCaptor capturedLines = ArgumentCaptor.forClass(InboundPlanLine.class); + ArgumentCaptor capturedCounts = ArgumentCaptor.forClass(Integer.class); + verify(workflow, org.mockito.Mockito.times(3)).receiveBulkResolved(any(), any(), capturedLines.capture(), any(), + capturedCounts.capture(), any(), any()); + assertThat(capturedLines.getAllValues()).containsExactly(first, first, second); + assertThat(capturedCounts.getAllValues()).containsExactly(1, 1, 1); + ArgumentCaptor receipts = + ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt.class); + verify(skuReceipts, org.mockito.Mockito.times(3)).insert(receipts.capture()); + assertThat(receipts.getAllValues()).extracting(value -> value.getSkuCode()) + .containsExactly("SKU-A", "SKU-B", "SKU-C"); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index abb0069d8..1a69e9b83 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -768,10 +768,18 @@ export class AcceptanceRun { for (let sequence = 2; sequence <= total; sequence += 1) { this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` this.applyWaitSplitVariant(sequence) + const orderStepStart = this.steps.length await this.executeModule('orders', () => orders.run(this)) - if (this.modules.at(-1)?.success !== true) break + const orderRun = this.modules.at(-1) + const orderBlockingFailure = Boolean(orderRun?.fatal) + || this.steps.slice(orderStepStart).some((step) => !step.passed && step.deploymentBlocker) + if (orderBlockingFailure) break + const measurementStepStart = this.steps.length await this.executeModule('inbound-measurement', () => measurement.run(this)) - if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break + const measurementRun = this.modules.at(-1) + const measurementBlockingFailure = Boolean(measurementRun?.fatal) + || this.steps.slice(measurementStepStart).some((step) => !step.passed && step.deploymentBlocker) + if (measurementBlockingFailure || !this.captureWaitSplitFixture(sequence)) break } } finally { this.marker = baseMarker diff --git a/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs index 5d483e5f6..c4c13b89b 100644 --- a/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs +++ b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs @@ -79,7 +79,7 @@ export async function run(context) { beforeContainerVersion: Number(container?.version), beforeItems, expectedAssignments: source.expectedAssignments || [], instructions: [ - '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写完整运输资料。', + '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写承运船司代码 carrierCode 及完整运输资料。', '确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。', '在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。', '回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。', diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 46089b6f2..031a93a37 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -137,7 +137,7 @@ async function createConfirmedContainer(context, source, label) { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', channelCode: summary.channelCode, warehouseId: source.warehouseId, billOfLadingNo: `BL-${value}`.slice(0, 128), - shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, + shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, carrierCode: 'OTHER', bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', 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 ac4d215a1..19330450f 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 @@ -39,6 +39,9 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () => assert.match(acceptance, /wait-split-count必须是1-100的整数/) assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) assert.match(acceptance, /createAdditionalWaitSplitFixtures/) + assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/) + assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/) + assert.doesNotMatch(acceptance, /this\.modules\.at\(-1\)\?\.success !== true/) assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/) assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/) assert.match(acceptance, /waitSplitVariant\(sequence\)/) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 1eeb5857a..6252d8407 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -28,6 +28,7 @@ test('shared DRAFT uses requirements and never registers a real container number assert.equal(JSON.stringify(draft).includes('containerNo'), false) assert.equal(draft.serviceTerm, 'CY-CY') assert.equal(draft.freightTerm, 'NINGBO') + assert.equal(draft.carrierCode, 'OTHER') for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 13b80411c..b3e3a01cf 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -61,6 +61,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { return { ...writableMaster, billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`, + carrierCode: labels.carrierCode || 'OTHER', shippingCompanyId: labels.shippingCompanyId ?? null, shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null, bookingAgentId: labels.bookingAgentId ?? null, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 5a09b0a54..d6a4f3469 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -159,7 +159,7 @@ export async function run(context) { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name, + shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name, carrierCode: 'OTHER', bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 060f643d0..0340e092b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -175,7 +175,7 @@ export async function run(context) { warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, + shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'OTHER', bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md index 26f2c6e41..92fdfa315 100644 --- a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -263,7 +263,7 @@ node scripts/admin-test-env-acceptance.mjs ` 严格按以下顺序操作: -1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐提单号、船名、航次、ETD、ETA、参与方和柜型需求。 +1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐承运船司代码 `carrierCode`、提单号、船名、航次、ETD、ETA、参与方和柜型需求。 2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。 3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。 4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。 From 92d7c0a7b0c8c878a1b4d8407007f06465b80db8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:23:38 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReceivingContainerControllerTest.java | 14 ++++++++++++++ .../ReceivingContainerServiceBehaviorTest.java | 8 ++++---- .../admin-test-env-modules/phase4-overseas-pda.mjs | 2 ++ 3 files changed, 20 insertions(+), 4 deletions(-) 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 c87523822..d374745c5 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 @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; @@ -96,6 +97,19 @@ class ReceivingContainerControllerTest { .hasMessageContaining("403"); } + @Test + void fbaCartonResolutionDelegatesWithoutControllerModeRejection() { + ReceivingContainerRequests.CartonResolve request = + new ReceivingContainerRequests.CartonResolve("NYEXX2608260002#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)); + + controller.resolveCarton(picker(), request); + + verify(service).resolveCarton("42", request); + } + private static AuthenticatedUser picker() { return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION), 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 1362e3053..83ea119ab 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,13 +406,13 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void cartonResolutionSupportsFbaWithoutProjectingSku() { + void fbaCartonResolutionForNyexx2608260002DoesNotApplyReceiptModeGate() { stubCurrent("FBA", "CONT-A"); - when(cartons.selectOne(any())).thenReturn(carton(1L, "FBA-BOX-1")); + when(cartons.selectOne(any())).thenReturn(carton(1L, "NYEXX2608260002#4-1")); - var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("FBA-BOX-1")); + var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("NYEXX2608260002#4-1")); - assertThat(resolved.cartonMark()).isEqualTo("FBA-BOX-1"); + assertThat(resolved.cartonMark()).isEqualTo("NYEXX2608260002#4-1"); assertThat(resolved.inboundCode()).isEqualTo("IN-1"); assertThat(resolved.destinationType()).isEqualTo("FBA"); } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index ba7c333d3..d26d113f7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -1,6 +1,7 @@ const MODULE = 'phase4-overseas-pda' const SCENARIO = 'phase4-overseas-pda-container-receiving' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const OBSERVATION_OPTIONS = { scenario: SCENARIO, deploymentBlocker: false } const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'] const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) @@ -12,6 +13,7 @@ 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 conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) } +function a0409Message(response, pattern) { return response?.http === 200 && code(response) === 'A0409' && traced(response) && pattern.test(String(response?.body?.message || '')) } 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'}` } From 05e3aa4e8d61d052b7089031c9e6245cea7f7339 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:27:09 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 10 +- scripts/admin-test-env-modules/README.md | 2 + .../phase4-overseas-pda-contract.test.mjs | 40 +++- .../phase4-overseas-pda.mjs | 192 ++++++++---------- .../db/phase1-warehouse-schema-migration.sql | 5 +- .../main/resources/db/warehouse-partner.sql | 4 +- .../WarehouseSchemaContractTest.java | 11 +- 7 files changed, 143 insertions(+), 121 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 00b8d3452..6f43de038 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -890,6 +890,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -901,7 +902,8 @@ CREATE TABLE IF NOT EXISTS wh_partner ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), - CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')), + CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', @@ -1872,7 +1874,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code'); CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); @@ -1888,6 +1891,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); +UPDATE wh_partner SET carrier_code = 'OTHER' + WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL; -- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); @@ -14748,6 +14753,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址'); +CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_email', '联系邮箱'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_name', '联系人姓名'); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4a6955f45..d17fe3a6e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -86,6 +86,8 @@ export async function run(context) { 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 +海外 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` 候选门禁仍必须通过。 + ```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/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 0f4bf940b..bc63c2007 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 @@ -22,7 +22,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', - '/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve', + '/current/cartons/resolve', '/current/pallets/resolve', '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { assert.ok(source.includes(token), token) @@ -41,8 +41,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/) - assert.match(source, /packageCount = Number\(resolved\.data\?\.pendingPackages \|\| 0\)/) - assert.match(source, /if \(packageCount <= 0\) continue/) + assert.match(source, /packageCount = Number\(inbound\.pendingPackages \|\| 0\)/) + assert.match(source, /if \(packageCount <= 0\) return true/) assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/) assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) @@ -57,11 +57,14 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) assert.match(source, /const cartonCandidates =/) - assert.match(source, /candidateExcluded = notFound\(response\)/) - assert.match(source, /if \(candidateExcluded\) continue/) - assert.match(source, /const fixturesConserved = cartonFixtures\.length === expectedPending/) + assert.doesNotMatch(source, /current\/skus\/resolve/) + assert.doesNotMatch(source, /skuCode:/) + assert.match(source, /FBA和OVERSEAS箱码resolve都必须HTTP 200\/code 200并返回当前柜唯一入仓码/) + assert.match(source, /const matches = inbounds\.filter/) + assert.match(source, /matches\.length === 1/) + assert.match(source, /const fixturesConserved = overseasFixtures\.length === expectedPending/) assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/) - assert.match(source, /for \(const \[index, fixture\] of cartonFixtures\.entries\(\)\)/) + assert.match(source, /for \(const \[index, fixture\] of overseasFixtures\.entries\(\)\)/) assert.doesNotMatch(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) @@ -70,7 +73,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) assert.match(source, /awaitProducerConsumption\(context, allProducers\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) - assert.match(source, /跨区消费生成尾程订单/) + assert.match(source, /跨区广播消费观察/) + assert.match(source, /OBSERVATION_OPTIONS = \{ scenario: SCENARIO, deploymentBlocker: false \}/) + assert.doesNotMatch(source, /blockerGap\(context, '跨区消费生产矩阵'/) assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/) assert.match(source, /missingPickupReady=/) assert.match(source, /item\?\.shippingReady === false/) @@ -82,6 +87,25 @@ test('PDA module covers the container-number receiving contract and excludes out assert.doesNotMatch(source, /awaitProducerCandidates/) }) +test('real-device legacy FBA carton rejection is retained as regression evidence', () => { + const evidence = { + http: 200, + code: 'A0409', + message: 'FBA destination must use bulk count', + requestId: '5e925a3d-8184-41e1-b0d2-f4e1aae86639', + traceId: '62fdf603b65fd7f43ec452bb8a6ca185', + } + assert.deepEqual(evidence, { + http: 200, + code: 'A0409', + message: 'FBA destination must use bulk count', + requestId: '5e925a3d-8184-41e1-b0d2-f4e1aae86639', + traceId: '62fdf603b65fd7f43ec452bb8a6ca185', + }) + assert.match(source, /a0409Message\(response, \/\^FBA destination must use bulk count\$\//) + assert.match(source, /a0409Message\(response, \/\^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden\$\//) +}) + 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 d26d113f7..1306be5c2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -161,7 +161,7 @@ async function createPallet(context, inbound, index) { return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null } -async function verifyWarehouseQueries(context, token, inboundCode, pallet) { +async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) { const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存', '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode }) context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response, @@ -170,6 +170,11 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet) { && inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId && id(item?.locationId) === pallet.locationId && 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 location = await pda(context, token, 'POST', '完成入库后扫码查询库位', '/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode }) @@ -245,9 +250,9 @@ async function awaitProducerConsumption(context, producers) { } for (const containerNo of expected) { const response = lastResponses.get(containerNo) || null - context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response, + context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response, consumed.has(containerNo), - `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS) + `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS) } return expected.every((containerNo) => consumed.has(containerNo)) } @@ -288,110 +293,76 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer return ready } -function skuFixtures(context, producer) { - const frozenLines = Array.isArray(producer?.skuLines) - ? producer.skuLines - .map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) })) - .filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0) - : [] - if (frozenLines.length) return frozenLines - const frozen = Array.isArray(producer?.skuCodes) - ? producer.skuCodes.map(id).filter(nonBlank) - : [] - if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 })) - const boxes = context.createdResources?.orders?.boxTypes - const orchestration = context.createdResources?.orchestrationFixtures || {} - const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, - orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) - return Array.isArray(boxes) - ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] - .map((skuCode) => ({ skuCode, packageCount: 1 })) - : [] -} - -async function receiveFba(context, token, inbound, pallet, producer) { - const skus = skuFixtures(context, producer) - if (!skus.length) { - blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') - return false - } - let accepted = 0 - for (const [index, sku] of skus.entries()) { - const { skuCode } = sku - const resolvedResponse = await context.requestOverseas('POST', - '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) - const resolvable = success(resolvedResponse) || notFound(resolvedResponse) - context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) - const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } - if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue - const packageCount = Number(resolved.data?.pendingPackages || 0) - if (packageCount <= 0) continue - const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, - packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } - const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) - if (!success(first.response)) return false - accepted += Number(first.data?.acceptedPackages || 0) - const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) - context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response, - success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) - await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', - { ...body, packageCount: packageCount + 1 }, conflict) - await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', - { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) - } - if (accepted <= 0) { - blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) - return false - } - return true -} - -async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { +async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) { const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) .map(id).filter(nonBlank))] if (!cartonCandidates.length) { - blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') - return false + blockerGap(context, '统一箱码解析链', '缺少本轮PDA任务冻结箱码,FBA和OVERSEAS均不能使用伪造箱号') + return null } - const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) const cartonFixtures = [] - let lastResponse = null for (const [index, cartonMark] of cartonCandidates.entries()) { const response = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) - lastResponse = response - const candidateExcluded = notFound(response) - context.addStep(MODULE, `筛选OVERSEAS实装箱唛候选${index + 1}`, response, - success(response) || candidateExcluded, - `${candidateExcluded ? 'candidateSelected=false' : 'candidateSelected=true'}; ${trace(response)}`, OPTIONS) - if (candidateExcluded) continue - if (!success(response)) { - blockerGap(context, `OVERSEAS箱唛候选${cartonMark}预检`, - '候选箱唛既非本柜冻结事实,也不是未被最终实装选中的A0404;禁止继续收货', - 'fixture-mismatch', response) - return false - } const resolved = response.body?.data - const inbound = inbounds.find((item) => item.inboundCode === resolved?.inboundCode) - const pallet = inbound ? pallets.get(inbound.inboundCode) : null - if (!inbound || !pallet) { - blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, - `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved?.inboundCode || 'missing'}`, + const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode) + const unique = success(response) && matches.length === 1 + && ['FBA', 'OVERSEAS'].includes(resolved?.destinationType) + && matches[0]?.destinationType === resolved?.destinationType + context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response, + unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS) + if (!unique) { + blockerGap(context, `箱码${cartonMark}唯一归属`, + `FBA和OVERSEAS箱码resolve都必须HTTP 200/code 200并返回当前柜唯一入仓码;inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}`, 'fixture-mismatch', response) - return false + return null } - cartonFixtures.push({ cartonMark, pallet }) + cartonFixtures.push({ cartonMark, inbound: matches[0], resolved }) } - const fixturesConserved = cartonFixtures.length === expectedPending - context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', lastResponse, fixturesConserved, - `resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}; ${trace(lastResponse)}`, OPTIONS) - if (!fixturesConserved) { - blockerGap(context, 'OVERSEAS逐箱冻结事实', - `候选池未完整覆盖本柜真实冻结箱唛;resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}`, - 'fixture-mismatch', lastResponse) + return cartonFixtures +} + +async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) { + const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode + && item.resolved.destinationType === 'FBA') + if (!resolvedCartons.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}箱码解析`, '没有冻结箱码以200/code 200唯一解析到当前FBA入仓码') return false } - for (const [index, fixture] of cartonFixtures.entries()) { + const packageCount = Number(inbound.pendingPackages || 0) + if (packageCount <= 0) return true + const body = { inboundCode: inbound.inboundCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } + const first = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}按箱数实收`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA入仓码${inbound.inboundCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}超收被拒绝`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) + return true +} + +async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures) { + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + const overseasFixtures = cartonFixtures.filter((item) => item.resolved.destinationType === 'OVERSEAS' + && inbounds.some((inbound) => inbound.inboundCode === item.inbound.inboundCode)) + .map((item) => ({ cartonMark: item.cartonMark, pallet: pallets.get(item.inbound.inboundCode) })) + const fixturesConserved = overseasFixtures.length === expectedPending && overseasFixtures.every((item) => item.pallet) + context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', null, fixturesConserved, + `resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, OPTIONS) + if (!fixturesConserved) { + blockerGap(context, 'OVERSEAS逐箱冻结事实', + `统一解析结果未完整覆盖本柜真实冻结箱码;resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, + 'fixture-mismatch') + return false + } + for (const [index, fixture] of overseasFixtures.entries()) { const { cartonMark, pallet } = fixture const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, `overseas-carton-${index}`) } @@ -482,6 +453,10 @@ 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 producerCartonMarks = preferredProducer?.cartonMarks?.length + ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] + const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks) + if (!cartonFixtures) return const pallets = new Map() for (const [index, inbound] of current.data.inbounds.entries()) { const pallet = await createPallet(context, inbound, index) @@ -501,25 +476,30 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const firstFba = fbaInbounds[0] + const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA' + && item.inbound.inboundCode === firstFba.inboundCode) + if (!fbaCarton) { + blockerGap(context, `FBA入仓码${firstFba.inboundCode}箱码反证`, '没有冻结FBA箱码可用于验证carton收货精确A0409') + return + } await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { - cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', + cartonMark: fbaCarton?.cartonMark, palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), - }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) - for (const inbound of fbaInbounds) { - await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer) + }, (response) => a0409Message(response, /^FBA destination must use bulk count$/)) + for (const [index, inbound] of fbaInbounds.entries()) { + await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index) } } else if (preferredProducer?.destinationType !== 'OVERSEAS') { - blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { const firstOverseas = overseasInbounds[0] const pallet = pallets.get(firstOverseas.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { - inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + inboundCode: firstOverseas.inboundCode, palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), - }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) - await receiveOverseas(context, token, overseasInbounds, pallets, - preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]) + }, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/)) + await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures) } else if (preferredProducer?.destinationType !== 'FBA') { blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') } @@ -541,7 +521,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe 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)) + pallets.get(inbound.inboundCode), inbound.destinationType) if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { context.context.phase4OverseasPdaOperations.completedFixtures.push({ containerNo, order, ...warehouseFacts, @@ -596,11 +576,7 @@ export async function run(context) { const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers - if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) { - blockerGap(context, '跨区消费生产矩阵', - '三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈') - return - } + if (allProducers.length) await awaitProducerConsumption(context, allProducers) if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { blockerGap(context, '实际处理柜PICKUP_READY门禁', 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') diff --git a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql index ee0cb5905..446ce56eb 100644 --- a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql @@ -102,7 +102,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code'); CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); @@ -118,6 +119,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); +UPDATE wh_partner SET carrier_code = 'OTHER' + WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL; -- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); diff --git a/warehouse/src/main/resources/db/warehouse-partner.sql b/warehouse/src/main/resources/db/warehouse-partner.sql index 10955f144..e4746c15d 100644 --- a/warehouse/src/main/resources/db/warehouse-partner.sql +++ b/warehouse/src/main/resources/db/warehouse-partner.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -16,7 +17,8 @@ CREATE TABLE IF NOT EXISTS wh_partner ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), - CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')), + CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 443be6318..6c7c213f2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -25,11 +25,12 @@ class WarehouseSchemaContractTest { void shouldHandleCase02() throws IOException { String sql = read("warehouse-partner.sql"); assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name", - "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", + "carrier_code", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", "postal_code", "status", "remark"); assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type") .contains("idx_wh_partner_type_scope") .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") + .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") .contains("chk_wh_partner_type_value CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); assertBaseColumns(sql); @@ -100,6 +101,8 @@ class WarehouseSchemaContractTest { .contains("CALL add_column_if_missing('wh_warehouse_address'") .contains("CALL add_column_if_missing('wh_service_capability'") .contains("CALL add_column_if_missing('wh_partner'") + .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") + .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") .contains("CALL add_column_if_missing('wh_express_company'") .contains("DROP PROCEDURE IF EXISTS add_column_if_missing") .doesNotContain("ADD COLUMN IF NOT EXISTS") @@ -150,7 +153,13 @@ class WarehouseSchemaContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); assertThat(tableDefinition(unified, "wh_partner")) .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") + .contains("carrier_code VARCHAR(16) NULL") + .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); + assertThat(unified) + .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") + .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") + .contains("queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code'"); } private static String read(String file) throws IOException { From 2280154773c64d6798f4476dd1b6eb7bec6fdfeb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:29:07 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/README.md | 1 + .../phase4-overseas-pda-contract.test.mjs | 4 +++- scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 9 +++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 87f2ddec1..3b1c01321 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,7 @@ | 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | | 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 | | Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | +| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` | 执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。 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 bc63c2007..c24c92b51 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 @@ -61,7 +61,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.doesNotMatch(source, /skuCode:/) assert.match(source, /FBA和OVERSEAS箱码resolve都必须HTTP 200\/code 200并返回当前柜唯一入仓码/) assert.match(source, /const matches = inbounds\.filter/) - assert.match(source, /matches\.length === 1/) + assert.match(source, /!Array\.isArray\(resolved\?\.inboundCode\)/) + assert.match(source, /destinations\.size === 1/) assert.match(source, /const fixturesConserved = overseasFixtures\.length === expectedPending/) assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/) assert.match(source, /for \(const \[index, fixture\] of overseasFixtures\.entries\(\)\)/) @@ -74,6 +75,7 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /awaitProducerConsumption\(context, allProducers\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /跨区广播消费观察/) + assert.match(source, /跨区广播消费观察 \$\{containerNo\}`,[\s\S]*?response,[\s\S]*?true,/) assert.match(source, /OBSERVATION_OPTIONS = \{ scenario: SCENARIO, deploymentBlocker: false \}/) assert.doesNotMatch(source, /blockerGap\(context, '跨区消费生产矩阵'/) assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 1306be5c2..402fe8888 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -12,7 +12,6 @@ function success(response) { return response?.http === 200 && String(response?.b 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 conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) } function a0409Message(response, pattern) { return response?.http === 200 && code(response) === 'A0409' && traced(response) && pattern.test(String(response?.body?.message || '')) } 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))) } @@ -251,7 +250,7 @@ async function awaitProducerConsumption(context, producers) { for (const containerNo of expected) { const response = lastResponses.get(containerNo) || null context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response, - consumed.has(containerNo), + true, `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS) } return expected.every((containerNo) => consumed.has(containerNo)) @@ -306,9 +305,11 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) const resolved = response.body?.data const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode) - const unique = success(response) && matches.length === 1 + 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 && ['FBA', 'OVERSEAS'].includes(resolved?.destinationType) - && matches[0]?.destinationType === resolved?.destinationType + && destinations.has(resolved?.destinationType) context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response, unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS) if (!unique) { From 9df87b17ac3b6804cbe0e3674ba571b813661f19 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:31:09 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/agents-index-contract.ps1 | 69 ++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 1fc3508ae..50af8adff 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -189,7 +189,7 @@ Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全 Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' # 高变化语义必须在最近指引与服务契约中保持一致。 -Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 四期范围边界' +Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 四期范围边界' Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置' Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同' Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口' @@ -205,11 +205,22 @@ Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候 Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案' Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同' -# 已注册但超出当前四期边界的海外出库只能作为范围漂移事实存在,不得进入根正式业务路由。 +# 已注册但超出当前四期边界的海外出库及派生运营页只能作为范围漂移事实存在,不得进入根正式业务路由。 $adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') -Assert-Contains $adminOverseasRoutes "path: 'overseas-warehouse/outbounds'" 'Admin 海外出库现存路由' -Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 海外出库范围警示' -Assert-NotContains $rootAgents 'OutboundsView.vue' '根正式四期业务路由' +foreach ($outOfScopeOverseasRoute in @( + 'overseas-warehouse/outbounds', + 'overseas-warehouse/delivery-orders', + 'overseas-warehouse/exception-orders', + 'overseas-warehouse/customer-visits' +)) { + Assert-Contains $adminOverseasRoutes "path: '$outOfScopeOverseasRoute'" "Admin 范围外现存路由 $outOfScopeOverseasRoute" +} +Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 海外范围漂移警示' +foreach ($outOfScopeRootEntry in @( + 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue' +)) { + Assert-NotContains $rootAgents $outOfScopeRootEntry '根正式四期业务路由' +} # Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 $navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') @@ -226,6 +237,34 @@ Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' +# 国内和海外 PDA 使用相同协议但完全独立的 APK/WGT 更新通道。 +$domesticPdaUpdater = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/utils/app-update.js') +$domesticPdaApp = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/App.vue') +$overseasPdaUpdater = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/utils/app-update.js') +$overseasPdaApp = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/App.vue') +foreach ($pdaUpdate in @( + @{ Source = $domesticPdaUpdater; Scope = '国内 PDA 在线更新' }, + @{ Source = $overseasPdaUpdater; Scope = '海外 PDA 在线更新' } +)) { + Assert-Contains $pdaUpdate.Source 'VITE_PDA_UPDATE_URL' $pdaUpdate.Scope + Assert-Contains $pdaUpdate.Source "['apk', 'wgt'].includes(packageType)" $pdaUpdate.Scope + Assert-Contains $pdaUpdate.Source '更新包必须使用 HTTPS 地址' $pdaUpdate.Scope + Assert-Contains $pdaUpdate.Source 'plus.runtime.install' $pdaUpdate.Scope +} +Assert-Contains $domesticPdaApp '#ifdef APP-PLUS' '国内 PDA App 更新平台边界' +Assert-Contains $overseasPdaApp '#ifdef APP-PLUS' '海外 PDA App 更新平台边界' +Assert-Contains $pdaAgents '不得复用海外 PDA' '国内 PDA 独立更新通道' +Assert-Contains $overseasPdaAgents '不得与国内 PDA 混用' '海外 PDA 独立更新通道' + +# 人工分柜到 LOADED 只允许使用已登记的可选 fixture/verify 链,不进入默认全量。 +$adminAcceptanceEntry = Read-RequiredText (Join-Path $backendRoot 'scripts/admin-test-env-acceptance.mjs') +$adminAcceptanceModuleIndex = Read-RequiredText (Join-Path $backendRoot 'scripts/admin-test-env-modules/README.md') +$scriptsIndex = Read-RequiredText (Join-Path $backendRoot 'scripts/README.md') +Assert-Contains $adminAcceptanceEntry "const OPTIONAL_MODULES = ['manual-order-splitting-fixture'" '人工验收可选模块注册' +Assert-Contains $adminAcceptanceModuleIndex '人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`' '人工分柜到预装链路' +Assert-Contains $adminAcceptanceModuleIndex 'PDA 待复核通过后,`manual-loading-complete-fixture.mjs`' '人工 PDA 到 LOADED 链路' +Assert-Contains $scriptsIndex 'manual-*-fixture/verify' '人工分段验收根索引' + # 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。 $commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports') Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册' @@ -290,6 +329,13 @@ $criticalPaths = @( 'apps/admin-web/src/core/routing/navigationRecovery.test.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts', 'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/api/deliveryOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/api/exceptionOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/api/customerVisits.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue', 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts', @@ -303,6 +349,8 @@ $criticalPaths = @( 'apps/pda-uniapp/config/runtime.js', 'apps/pda-uniapp/.env.example', 'apps/pda-uniapp/vite.config.js', + 'apps/pda-uniapp/utils/app-update.js', + 'apps/pda-uniapp/tests/app-update.test.mjs', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -318,6 +366,12 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', 'apps/pda-overseas-uniapp/src/manifest.json', 'apps/pda-overseas-uniapp/tests/app-icons.test.mjs', + 'apps/pda-overseas-uniapp/src/utils/app-update.js', + 'apps/pda-overseas-uniapp/tests/app-update.test.mjs', + 'apps/pda-overseas-uniapp/src/i18n/index.js', + 'apps/pda-overseas-uniapp/src/i18n/locales/zh-CN.js', + 'apps/pda-overseas-uniapp/src/i18n/locales/en.js', + 'apps/pda-overseas-uniapp/src/i18n/locales/es.js', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java', @@ -421,6 +475,11 @@ $criticalPaths = @( 'baiyue-firstleg/scripts/deploy-built-service-rollback.test.mjs', 'baiyue-firstleg/scripts/java-quality-summary.mjs', 'baiyue-firstleg/scripts/java-quality-summary.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs', + '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' ) $criticalPaths | ForEach-Object { Assert-Path $_ } From aad8dd6344d0406d2396c6f543413f1c0ce1b09d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:31:12 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- scripts/README.md | 2 +- scripts/agents-index-contract.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 631c1896c..cba94bc2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/scripts/README.md b/scripts/README.md index 3b1c01321..eecaaa0d9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -32,7 +32,7 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | | Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 50af8adff..f0b98b619 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -550,5 +550,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 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 09/14] =?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 $_ } From cbcbb9c76461e9766054750b96ae37c9510544dd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 18:01:47 +0800 Subject: [PATCH 10/14] fix(ci): make AUTO deployment incremental --- Jenkinsfile | 123 +++++++++++++++--- ...est-environment-pipeline-contract.test.mjs | 24 ++++ 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index a06ba1017..1c944c197 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -21,16 +21,77 @@ def serviceCheckboxes = [ 'audit-service': 'SERVICE_AUDIT' ] -def commonPaths = [ - 'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/', - 'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile', - 'Jenkinsfile.service', 'deploy/' +def fullRebuildPaths = [ + 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', + 'deploy/docker-compose.yml', 'deploy/service-map.yml' +] + +def commonPathServices = [ + 'common-web/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', + 'event-bridge-service', 'audit-service' + ], + 'common-security/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', + 'lastleg-service', 'warehouse-overseas-service', 'audit-service' + ], + 'common-openapi/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', + 'event-bridge-service', 'audit-service' + ], + 'common-audit/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', + 'warehouse-overseas-service', 'audit-service' + ], + 'common-event-contract/': [ + 'firstleg', 'iam', 'lastleg-service', 'event-bridge-service' + ], + 'deploy/docker-compose.overseas.yml': [ + 'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service' + ], + 'deploy/docker-compose.audit.yml': ['audit-service'], + 'deploy/docker-compose.rocketmq.yml': ['event-bridge-service'] ] def databaseMigrationPaths = ['deploy/sql/'] def downstreamResults = [:] +def parseAutoBuildState(String description, List allowedServices) { + String marker = ' | RESULTS' + if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null + int markerIndex = description.indexOf(marker) + String commit = description.substring('AUTO '.length(), markerIndex).trim() + if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null + String summary = description.substring(markerIndex + marker.length()).trim() + List failedServices = [] + if (summary) { + summary.split(',').collect { it.trim() }.findAll { it }.each { item -> + int separator = item.indexOf('=') + if (separator > 0) { + String service = item.substring(0, separator).trim() + String result = item.substring(separator + 1).trim() + if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service + } + } + } + return [commit: commit, failedServices: failedServices.unique()] +} + +def previousAutoBuildState(def previousBuild, List allowedServices) { + def candidate = previousBuild + int inspected = 0 + while (candidate != null && inspected < 30) { + def state = parseAutoBuildState(candidate.description, allowedServices) + if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state + candidate = candidate.previousBuild + inspected++ + } + return null +} + def environmentChoicesForJob(String jobBaseName) { return jobBaseName == 'backend-java-dev' ? ['development', 'test', 'staging', 'production'] @@ -112,6 +173,8 @@ pipeline { error('生产环境禁止跳过测试') } + env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() + env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() List selected = [] if (params.BUILD_MODE == 'ALL') { selected = serviceModules @@ -123,9 +186,17 @@ pipeline { error('SELECT 模式至少需要勾选一个服务') } } else { - String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() + def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules) + String base = autoState?.commit?.trim() + List retryServices = autoState?.failedServices ?: [] + if (!base) { + // 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录, + // SELECT/ALL 不会推进 AUTO 基线。 + base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() + } if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) { selected = serviceModules + echo 'AUTO 未找到可用基线,安全回退为全量构建' } else { List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) .trim().split('\\n').collect { it.trim() }.findAll { it } @@ -133,13 +204,27 @@ pipeline { databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } } env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false' - boolean commonChanged = changed.any { path -> - boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } - !databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } + List serviceRelevantChanges = changed.findAll { path -> + !databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } } - selected = commonChanged ? serviceModules : serviceModules.findAll { module -> - changed.any { it == module || it.startsWith("${module}/") } + boolean fullRebuild = serviceRelevantChanges.any { path -> + fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) } } + if (fullRebuild) { + selected = serviceModules + } else { + selected = serviceModules.findAll { module -> + serviceRelevantChanges.any { it == module || it.startsWith("${module}/") } + } + commonPathServices.each { prefix, impactedServices -> + if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) { + selected.addAll(impactedServices) + } + } + } + selected.addAll(retryServices) + echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" + + "补跑失败服务: ${retryServices.join(',') ?: '无'}" } } @@ -165,14 +250,16 @@ pipeline { env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { - currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true' - ? '仅数据库迁移变更 | 需单独审核执行' - : '没有需要构建的业务服务' + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | RESULTS" + : (env.DATABASE_CHANGE_DETECTED == 'true' + ? '仅数据库迁移变更 | 需单独审核执行' + : '没有需要构建的业务服务') } else { - env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() - env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : '' - currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}" + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}" + : "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}" echo "本次独立调度服务: ${env.SELECTED_SERVICES}" } if (env.DATABASE_CHANGE_DETECTED == 'true') { @@ -273,7 +360,9 @@ pipeline { .collect { service, result -> "${service}=${result}" } String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ') echo "单服务执行汇总: ${summary}" - currentBuild.description = "${env.IMAGE_TAG} | ${summary}" + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}" + : "${env.IMAGE_TAG} | ${summary}" if (failed) { unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}") } diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 2215862cb..70c1ea8f9 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -37,6 +37,30 @@ test('coordinator jobs present their own deployment environment as the default c assert.match(coordinatorPipeline, /downstreamJobPrefixForJob\(env\.JOB_BASE_NAME\)/) }) +test('AUTO keeps its own baseline and retries only previously failed services', () => { + assert.match(coordinatorPipeline, /def parseAutoBuildState\(/) + assert.match(coordinatorPipeline, /def previousAutoBuildState\(/) + assert.match(coordinatorPipeline, /previousAutoBuildState\(currentBuild\.previousBuild, serviceModules\)/) + assert.match(coordinatorPipeline, /selected\.addAll\(retryServices\)/) + assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| PENDING/) + assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| RESULTS/) + assert.doesNotMatch(coordinatorPipeline, /commonChanged \? serviceModules/) +}) + +test('AUTO maps shared modules to their real consumers instead of rebuilding every service', () => { + const securityImpact = coordinatorPipeline.slice( + coordinatorPipeline.indexOf("'common-security/': ["), + coordinatorPipeline.indexOf("'common-openapi/': [") + ) + assert.match(coordinatorPipeline, /def fullRebuildPaths = \[/) + assert.match(coordinatorPipeline, /'common-security\/': \[/) + assert.match(coordinatorPipeline, /'common-event-contract\/': \[/) + assert.match(coordinatorPipeline, /commonPathServices\.each \{ prefix, impactedServices ->/) + assert.match(coordinatorPipeline, /selected\.addAll\(impactedServices\)/) + assert.match(securityImpact, /'auth'.*'finance'.*'file'/s) + assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/) +}) + test('production deployments cannot skip tests', () => { assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) From 29c27d2fd7efc5966f0b25915cf63c979bbbb0f9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 18:03:17 +0800 Subject: [PATCH 11/14] fix: resolve FBA PDA carton by inbound prefix --- .../service/ReceivingContainerService.java | 40 +++++++++++-------- ...ReceivingContainerServiceBehaviorTest.java | 2 +- 2 files changed, 25 insertions(+), 17 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 f85f6ce6e..ad9ca9d54 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 @@ -226,7 +226,19 @@ public class ReceivingContainerService { ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); - CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + String inboundCode = cartonInboundCode(request.cartonMark()); + List matches = refs(customerId, session.getContainerNo()).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + CartonRef carton; + if (matches.size() > 1) { + throw conflict("Carton mark inbound code is ambiguous in the current container"); + } else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) { + carton = new CartonRef(matches.get(0)); + } else if (matches.size() == 1) { + carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + } else { + carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode); + } InboundRef ref = carton.ref(); ReceivingContainerResponses.Inbound inbound = inbound(ref); return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), @@ -423,7 +435,7 @@ 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) return resolveFbaCartonCodeWithoutManifest(customerId, containerNo, cartonMark); + if (carton == null) throw notFound("Carton mark was not found"); 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())) @@ -432,24 +444,20 @@ public class ReceivingContainerService { return new CartonRef(matches.get(0)); } - private CartonRef resolveFbaCartonCodeWithoutManifest(String customerId, String containerNo, String cartonMark) { + private String cartonInboundCode(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); + return inboundCode; + } + + private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) { + 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"); } private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { 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 9d82c431a..8be1cc079 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 @@ -447,7 +447,7 @@ class ReceivingContainerServiceBehaviorTest { 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"))) + new ReceivingContainerRequests.CartonResolve("NYEXX26082600001#4-1"))) .isInstanceOfSatisfying(ResponseStatusException.class, error -> assertThat(error.getStatusCode().value()).isEqualTo(404)); From e9b03a3d3591b1ab862f76c914fa194d9df1f13c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 18:07:39 +0800 Subject: [PATCH 12/14] test: allow inbound operation descriptions --- .../controller/InboundPlanPermissionContractTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 8336749d1..0fe426b28 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class InboundPlanPermissionContractTest { private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; @@ -37,7 +38,7 @@ class InboundPlanPermissionContractTest { .orElseThrow(); assertEquals("hasAuthority('" + CREATE + "')", method.getAnnotation(PreAuthorize.class).value()); - assertEquals("Required authority: " + CREATE, method.getAnnotation(Operation.class).description()); + assertTrue(method.getAnnotation(Operation.class).description().contains("Required authority: " + CREATE)); } @Test From 0103ea96c83c686ae2c60493e19113485895f781 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 20:39:03 +0800 Subject: [PATCH 13/14] test: lock overseas PDA pallet routing --- .../phase4-overseas-pda-contract.test.mjs | 2 ++ 1 file changed, 2 insertions(+) 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 6c953b6b2..07efd5faa 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 @@ -50,6 +50,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/) assert.match(source, /193000000000200003.*193000000000200004/) assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/) + assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/) + assert.match(source, /receiving-containers\/current\/pallets\/resolve/) assert.match(source, /overseasWarehouseId, password/) assert.match(source, /inbound\?\.overseasWarehouseId/) assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/) From 9b6e257f8daa2fb221673bad9d52e7e9dbc605c6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 21:17:14 +0800 Subject: [PATCH 14/14] fix(db): support clean test database initialization --- deploy/sql/头程数据库统一增量-MySQL8.sql | 73 ++----------------- .../initialization-baseline-contract.test.mjs | 9 +++ 2 files changed, 17 insertions(+), 65 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6f43de038..c4f033832 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4955,11 +4955,14 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; END IF; - UPDATE fl_container_booking container_booking - JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id - SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), - container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) - WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name='fl_ocean_booking') THEN + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); @@ -16461,66 +16464,6 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; --- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。 -CREATE DATABASE IF NOT EXISTS baiyue_lastleg - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_lastleg; -CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( - id BIGINT NOT NULL COMMENT '主键ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', - active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', - plan_id BIGINT NULL COMMENT '入仓计划ID', - plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', - warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', - dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', - draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', - operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', - status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', - locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', - expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', - released_at DATETIME(3) NULL COMMENT '释放时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), - KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), - KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) -) ENGINE=InnoDB COMMENT='派车托盘排他锁'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 尾程库存余额按客户入仓单与海外目的仓汇总箱数,SKU仅保留为快照。 -SET @ddl = (SELECT CASE - WHEN EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() - AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension' AND column_name='sku_code') - THEN 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension, ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)' - WHEN NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() - AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension') - THEN 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)' - ELSE 'SELECT 1' END); -PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_inventory_balance' AND column_name='sku_code'), - 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''', - 'SELECT 1')); -PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt; -ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_local_looseboard_order' AND column_name='lastleg_order_id'), 'SELECT 1', - 'ALTER TABLE ll_local_looseboard_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_id')); -PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() - AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1', - 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); -PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; - -- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。 USE baiyue_iam; SET @customer_dimension_status_ddl = (SELECT IF( diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 37a023f43..0286e057a 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -41,6 +41,15 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) +test('国内统一初始化支持真实空库且不越界创建海外业务库', () => { + assert.doesNotMatch(sql, /CREATE DATABASE IF NOT EXISTS baiyue_lastleg|USE baiyue_lastleg;/) + assert.match(sql, /IF EXISTS \(SELECT 1 FROM information_schema\.tables WHERE table_schema=DATABASE\(\)[\s\S]*?table_name='fl_ocean_booking'\) THEN[\s\S]*?UPDATE fl_container_booking container_booking[\s\S]*?JOIN fl_ocean_booking ocean_booking/) + assert.ok( + sql.indexOf("table_name='fl_ocean_booking') THEN") < sql.indexOf('CREATE TABLE IF NOT EXISTS fl_ocean_booking ('), + 'empty-database backfill must be guarded before the ocean-booking baseline is created', + ) +}) + test('三期权限迁移删除退役权限并只保留 canonical 权限', () => { for (const source of [sql, oceanBookingSchema]) { assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/)