From 6aca41427ccd282c2d823334bc3481c1b43dfb71 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 15:38:28 +0800 Subject: [PATCH] feat(phase4): automate overseas PDA inbound lifecycle --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 + .../main/resources/application-overseas.yml | 2 +- .../OverseasLastlegRouteContractTest.java | 4 +- .../main/resources/db/lastleg-permissions.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 3 +- .../mapper/InboundPlanCartonMapper.java | 11 + .../inboundplan/pda/dto/PdaResponses.java | 7 +- .../pda/repository/PdaWorkflowRepository.java | 1 + .../service/InboundPdaWorkflowService.java | 16 +- .../service/InboundPlanService.java | 3 +- .../lastleg/service/LastlegOrderService.java | 12 + .../InboundPdaWorkflowServiceTest.java | 6 + .../service/InboundPlanServiceTest.java | 18 ++ .../service/LastlegOrderServiceTest.java | 9 + scripts/admin-test-env-acceptance.mjs | 27 ++- scripts/admin-test-env-modules/README.md | 5 +- .../phase4-overseas-pda-contract.test.mjs | 48 ++++ .../phase4-overseas-pda.mjs | 210 ++++++++++++++++++ ...verseas-acceptance-entry-contract.test.mjs | 2 + 19 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 51b63c660..ccc7e9bf0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16213,6 +16213,7 @@ VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), + (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index 5866a7caf..f7204c15d 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -10,7 +10,7 @@ spring: routes: - id: lastleg-service uri: lb://lastleg-service - predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"] + predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"] - id: warehouse-overseas-service uri: lb://warehouse-overseas-service predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java index 1a054fb2c..e83bb3312 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class OverseasLastlegRouteContractTest { @Test - void platformDispatchPathIsForwardedToLastlegWithoutPrefixRewrite() throws IOException { + void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException { try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { assertThat(input).isNotNull(); String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -22,6 +22,8 @@ class OverseasLastlegRouteContractTest { assertThat(route).contains("uri: lb://lastleg-service") .contains("/api/platform/v1/last-leg/**") + .contains("/api/merchant/v1/last-leg/**") + .contains("/api/pda/v1/last-leg/**") .doesNotContain("StripPrefix", "RewritePath"); } } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 5bd6ff291..f8aebcd23 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -19,6 +19,7 @@ VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), + (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index c333f01e1..01b84837d 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -39,6 +39,7 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8); Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); + assertThat(sql).contains("'lastleg:pda:operate'"); assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); @@ -53,7 +54,7 @@ class OverseasPhase4PermissionSqlContractTest { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); - assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission"); + assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); assertCustomAdminGrant(sql); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java index f95d5cbc7..742781c8a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java @@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; + @Mapper public interface InboundPlanCartonMapper extends BaseMapper { @Select(""" @@ -31,4 +33,13 @@ public interface InboundPlanCartonMapper extends BaseMapper { WHERE l.plan_id=#{planId}) """) boolean hasLabelPool(@Param("planId") Long planId); + + @Select(""" + SELECT DISTINCT c.* FROM ll_inbound_plan_carton c + JOIN ll_order_detail d ON d.source_inbound_detail_id=c.source_inbound_detail_id + JOIN ll_inbound_plan_line l ON l.order_id=d.order_id + WHERE l.plan_id=#{planId} AND c.customer_id=l.customer_id + ORDER BY c.id + """) + List listExpected(@Param("planId") Long planId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 4abcdd0fd..ca79c2b25 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -11,7 +11,8 @@ public final class PdaResponses { LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt, - List pallets, List allocations) {} + List pallets, List allocations, + List expectedCartons) {} public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, @@ -24,6 +25,10 @@ public final class PdaResponses { int countedPackageCount, int pickedPackageCount, String status, int version) {} + public record ExpectedCarton(String cartonMark, String skuCode, + String destinationWarehouseId, String destinationWarehouseCode, + long planPalletId, String status) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} public record Operation(String result, long allocationId, int requiredPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index 6d8dd4cbe..5f8f1f8b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -63,6 +63,7 @@ public class PdaWorkflowRepository { public List cartons(Long planId) { return cartons.selectList(new LambdaQueryWrapper().eq(InboundPlanCarton::getPlanId, planId)); } + public List expectedCartons(Long planId) { return cartons.listExpected(planId); } public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) { return cartons.findPlanLine(planId, carton.getSourceInboundDetailId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 9a8a4daec..e34ab20bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -650,9 +650,23 @@ public class InboundPdaWorkflowService { InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(), InboundPdaWorkflowService.value(value.getVersion())); }).toList(); + List expectedCartons = repository.expectedCartons(task.getPlanId()).stream() + .map(value -> new PdaResponses.ExpectedCarton(value.getCartonMark(), value.getSkuCode(), + value.getDestinationWarehouseId(), value.getDestinationWarehouseCode(), + recommendedPallet(value, planPallets), value.getStatus())) + .toList(); return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), - task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations); + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations, + expectedCartons); + } + + private long recommendedPallet(InboundPlanCarton carton, List pallets) { + if (carton.getPlanPalletId() != null) return carton.getPlanPalletId(); + return pallets.stream().filter(value -> Objects.equals(value.getDestinationWarehouseId(), carton.getDestinationWarehouseId()) + || (carton.getDestinationWarehouseId() == null + && Objects.equals(value.getDestinationWarehouseCode(), carton.getDestinationWarehouseCode()))) + .map(InboundPlanPallet::getId).findFirst().orElse(0L); } private List unloadPhotoFileIds(InboundPdaTask task) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 8ec5e1d9d..7ef011637 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -224,7 +224,8 @@ public class InboundPlanService { private void validateOrders(List orders) { for (SourceOrderSnapshot order : orders) { - if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) { + if (!("PENDING_ASSIGNMENT".equals(order.currentStatus()) + || "DESTINATION_ASSIGNED".equals(order.currentStatus()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 1f2845e1c..0dd2de096 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -35,6 +37,7 @@ public class LastlegOrderService { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -88,8 +91,17 @@ public class LastlegOrderService { detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); detailUpdate.setDestinationWarehouseName(request.warehouseName()); detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, order.getId())); + InboundPlanCarton cartonUpdate = new InboundPlanCarton(); + cartonUpdate.setDestinationWarehouseId(request.warehouseId()); + cartonUpdate.setDestinationWarehouseCode(request.warehouseCode()); + cartonUpdate.setDestinationWarehouseName(request.warehouseName()); + cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) + .eq(InboundPlanCarton::getOrderId, order.getId())); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 27e13436b..dc7127c2d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -101,6 +101,9 @@ class InboundPdaWorkflowServiceTest { when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); when(repository.allocations(plan.getId())).thenReturn(List.of(target)); when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + InboundPlanCarton expected = carton("BOX-INSTRUCTION", "EXPECTED", targetLine.getId(), "WH-A"); + expected.setSkuCode("SKU-A"); + when(repository.expectedCartons(plan.getId())).thenReturn(List.of(expected)); var result = service.task("100", "LIP1"); @@ -108,6 +111,9 @@ class InboundPdaWorkflowServiceTest { assertEquals(target.getId(), result.allocations().get(0).allocationId()); assertEquals("RK-A", result.allocations().get(0).inboundCode()); assertEquals(3, result.allocations().get(0).plannedPackageCount()); + assertEquals("BOX-INSTRUCTION", result.expectedCartons().get(0).cartonMark()); + assertEquals("SKU-A", result.expectedCartons().get(0).skuCode()); + assertEquals(targetPallet.getId(), result.expectedCartons().get(0).planPalletId()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 7dd827a96..43ff8cbcf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -95,6 +95,24 @@ class InboundPlanServiceTest { assertEquals("CONT-1", result.containerNo()); } + @Test + void convertsAConsistentDestinationAssignedOrder() { + SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2", + "DESTINATION_ASSIGNED", null, new BigDecimal("1.0000"), 10, + "2090000000000000001", "LAX-02", "Los Angeles 02", 1, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(assigned)); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convert("100", 2L); + + assertEquals(2L, result.sourceOrderId()); + ArgumentCaptor line = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(lineMapper).insert(line.capture()); + assertEquals("2090000000000000001", line.getValue().getDestinationWarehouseId()); + verify(planMapper).insert(any(InboundPlan.class)); + } + @Test void repeatedConversionReturnsExistingPlanWithoutWriting() { InboundPlan existing = new InboundPlan(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index fdc785be6..9f7a4f9ab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -31,6 +33,7 @@ class LastlegOrderServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; + @Mock InboundPlanCartonMapper cartons; LastlegOrderService service; @BeforeEach @@ -39,6 +42,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); + ReflectionTestUtils.setField(service, "cartonMapper", cartons); } @Test @@ -76,6 +80,10 @@ class LastlegOrderServiceTest { ArgumentCaptor cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); verify(details).update(cargo.capture(), any()); assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); + ArgumentCaptor carton = ArgumentCaptor.forClass(InboundPlanCarton.class); + verify(cartons).update(carton.capture(), any()); + assertEquals("wh-2", carton.getValue().getDestinationWarehouseId()); + assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode()); ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); verify(histories).insert(history.capture()); assertEquals("100", history.getValue().getCustomerId()); @@ -93,6 +101,7 @@ class LastlegOrderServiceTest { assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); verify(orders, never()).updateById(any(LastlegOrder.class)); verify(details, never()).update(any(), any()); + verify(cartons, never()).update(any(), any()); } @Test diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 13035edfa..7f3de861f 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -13,7 +13,7 @@ const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -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-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-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 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-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-overseas-pda', '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'] export function normalizeModuleOrder(modules) { const normalized = [...modules] @@ -266,6 +266,31 @@ export class AcceptanceRun { return this.requestWithBody(method, path, form, token, extraHeaders) } + async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) { + const form = file instanceof FormData ? file : new FormData() + if (!(file instanceof FormData)) { + if (!file || file.content === undefined) throw new TypeError('multipart file requires content') + const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) + form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') + } + const headers = { Accept: 'application/json', ...extraHeaders } + if (token) headers.Authorization = `Bearer ${token}` + try { + const response = await fetch(`${this.options.overseasGateway}${path}`, { + method, headers, body: form, signal: AbortSignal.timeout(60_000), + }) + const content = await response.text(); let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ } + return { method, path, http: response.status, body: payload, + raw: payload === null ? content.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method, path, http: 0, body: null, + raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } + } + } + async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 20752ba46..c576b622e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -55,6 +55,7 @@ export async function run(context) { | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | +| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | @@ -62,10 +63,10 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;船节点禁止手工创建任务。所有模块继续禁止调用海外出库、派送、PDA 或内部接口。开发环境定向执行示例: +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway http://8.163.49.32:9000 --overseas-gateway http://8.163.49.32:9300 --modules phase4-overseas-warehouse,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.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 new file mode 100644 index 000000000..aae1a22e9 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { run } from './phase4-overseas-pda.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') + +test('PDA module uses only injected development acceptance requests', () => { + assert.match(source, /export async function run\(context\)/) + assert.match(source, /context\.requestOverseas\(/) + assert.match(source, /context\.requestOverseasMultipart\(/) + assert.doesNotMatch(source, /https?:\/\//) + assert.doesNotMatch(source, /process\.env|fetch\(/) +}) + +test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => { + for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/', + '/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) { + assert.ok(source.includes(token), token) + } + for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /requestId=/) + assert.match(source, /traceId=/) + assert.match(source, /PENDING_ASSIGNMENT/) + assert.match(source, /expectedCartons/) +}) + +test('missing source order is reported as a fixture prerequisite without business writes', async () => { + const calls = []; const gaps = []; const steps = [] + const context = { + marker: 'PDA-CONTRACT', createdResources: {}, + async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } }, + async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') }, + addStep(...args) { steps.push(args) }, + assert(...args) { steps.push(args) }, + addCoverageGap(...args) { gaps.push(args) }, + } + await run(context) + assert.equal(calls.length, 1) + assert.equal(calls[0].method, 'GET') + assert.equal(gaps.length, 1) + assert.match(gaps[0][3], /三期装柜完成事件链/) + assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs new file mode 100644 index 000000000..787abc34f --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -0,0 +1,210 @@ +const MODULE = 'phase4-overseas-pda' +const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function query(path, params) { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) + return `${path}?${search}` +} +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } + +async function domesticGet(context, name, path) { + const response = await context.request('GET', path) + const passed = success(response) + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return passed ? response.body.data : null +} +async function overseas(context, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body) + const passed = success(response) + context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) + return { response, data: passed ? response.body.data : null } +} + +async function findUnplannedOrder(context) { + const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')) + for (const tenant of tenants) { + const customerId = id(tenant?.id) + if (!longId(customerId)) continue + const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 })) + if (!success(plans.response)) continue + const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean)) + const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 })) + if (!success(orders.response)) continue + const order = records(orders.data).find((item) => longId(item?.id) && !planned.has(id(item.id))) + if (order) return { customerId, order } + } + return null +} + +async function createWarehouse(context, customerId) { + const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const warehouseCode = `PDA-${suffix}`.slice(0, 50) + const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', { + customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS', + countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA', + contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null, + latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null, + description: `${context.marker} PDA inbound acceptance` }], + }) + const warehouseId = id(created.data) + context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) + return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` } +} + +async function createResource(context, warehouseId, index, fbaWarehouseCode) { + const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase() + const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`, + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), + locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker, + }) + const locationId = id(location.data) + context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId), + `locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS) + const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`, + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2, + unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined, + remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null, + }) + const palletId = id(pallet.data) + context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId), + `palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS) + return { locationId, palletId } +} + +async function operateExpectedCartons(context, customerId, planNo, task) { + for (const [index, carton] of (task.expectedCartons || []).entries()) { + if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue + const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), { + cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId), + idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response, + scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark, + `cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS) + } +} + +async function operateLegacyAllocations(context, customerId, planNo, task) { + for (const [index, allocation] of (task.allocations || []).entries()) { + const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0) + if (remaining <= 0) continue + await overseas(context, 'POST', `清点入仓分配${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), { + packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)), + operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + await overseas(context, 'POST', `实收入仓分配${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), { + packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)), + operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + } +} + +export async function run(context) { + context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null, + planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true } + const source = await findUnplannedOrder(context) + if (!source) { + context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单', + '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') + return + } + const { customerId, order } = source + const warehouse = await createWarehouse(context, customerId) + if (!longId(warehouse.warehouseId)) return + Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId }) + + const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓', + query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), { + warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode, + warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`, + }) + if (!success(transferred.response)) return + const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', { + orderId: id(order.id), customerId, + }) + const plan = converted.data + const planNo = plan?.planNo || '' + context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response, + Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0, + `planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS) + if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return + context.createdResources.phase4OverseasPda.planNo = planNo + + for (const [index, planPallet] of plan.pallets.entries()) { + const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode) + if (!longId(resource.locationId) || !longId(resource.palletId)) return + const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`, + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), { + warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId, + idempotencyKey: key(context, 'assign', String(index + 1)), + }) + if (!success(assigned.response)) return + } + + const pushed = await overseas(context, 'POST', '推送PDA入仓任务', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), { + idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + const task = pushed.data + context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response, + longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length + && Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons), + `taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS) + if (!longId(task?.taskId)) return + context.createdResources.phase4OverseasPda.taskId = id(task.taskId) + + if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task) + else await operateLegacyAllocations(context, customerId, planNo, task) + + const photo = await context.requestOverseasMultipart('POST', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), { + name: `${context.marker}-unload.png`, type: 'image/png', + content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')), + }) + context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS) + const fileId = id(photo?.body?.data?.fileId) + context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS) + if (!longId(fileId)) return + const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), { + fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', + }) + context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response, + bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt), + `fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS) + + const completed = await overseas(context, 'POST', '完成PDA入仓并写库存', + query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId })) + context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response, + completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt), + `status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS) + context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null + + const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态', + query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId })) + context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED', + `planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS) + const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存', + query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 })) + context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response, + records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId), + `balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS) +} diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index e78eab28c..058b61d42 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -9,6 +9,7 @@ const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'u test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { for (const module of [ 'phase4-overseas-warehouse', + 'phase4-overseas-pda', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', @@ -28,5 +29,6 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) + assert.match(source, /async requestOverseasMultipart\(/) assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) })