diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index 0560e482f..b2e4b641c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -10,6 +10,8 @@ public class PlatformSmartLoadingController { @Resource private SmartLoadingService service; @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));} + @GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));} @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @@ -18,6 +20,8 @@ public class PlatformSmartLoadingController { public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));} @PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} + @PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));} @PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index dba00e1f0..68961190b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -1,6 +1,11 @@ package com.baiyue.firstleg.dto; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; public final class SmartLoadingDtos { private SmartLoadingDtos(){} + public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, + Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, + Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, + BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm) {} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { @@ -19,6 +24,9 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} + public record AssignInboundRequest(@NotNull @Positive Long inboundId, + @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max=128) String operationId) {} public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=128) String operationId) {} public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 336cc57e0..a0c6230b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -187,7 +187,10 @@ public class CarrierScheduleQueryService { } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), operatorId, operatorName); - CarrierScheduleQueryEntity after = advanced ? required(id) : value; + if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) { + throw concurrent(); + } + CarrierScheduleQueryEntity after = required(id); addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, operatorId, operatorName); return complete(id, operatorId, key, detail(after)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index cad44c116..5d48d045d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -31,6 +31,13 @@ public class SmartLoadingRepository { public Candidate candidate(long warehouseId, long inboundId, long detailId) { return candidates.candidate(warehouseId, inboundId, detailId); } + public List activeItemsByInbound(long planId, long accountId, long inboundId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getAccountId, accountId) + .eq(LoadingPlanItemEntity::getInboundId, inboundId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity planByBooking(long bookingId) { return plans.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index d8d77120c..efa936bbe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -58,6 +58,33 @@ public class SmartLoadingService { .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); } + /** Operator-facing candidates are grouped by inbound code; specification rows remain internal snapshots. */ + public List inboundCandidates(long bookingId, long warehouseId) { + Map> grouped = candidates(bookingId, warehouseId).stream() + .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + return grouped.values().stream().map(this::inboundCandidate).toList(); + } + + private InboundCandidate inboundCandidate(List details) { + Candidate first = details.get(0); + int packages = details.stream().map(Candidate::availablePackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + BigDecimal volume = details.stream() + .map(detail -> unit(detail.unitVolumeCbm()).multiply(BigDecimal.valueOf( + Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount())))) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP); + BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO : volume.divide( + BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); + String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、")); + return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), + first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), + first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), + first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), + averageVolume, volume); + } + public Plan draftByBooking(long bookingId) { ContainerBookingEntity booking = requireBooking(bookingId); LoadingPlanEntity plan = repository.planByBooking(bookingId); @@ -144,6 +171,84 @@ public class SmartLoadingService { return view(repository.plan(planId), booking); } + /** + * Assigns one inbound code by total carton count. The selected quantity is proportionally + * distributed over the remaining measured specifications only for volume estimation and + * downstream compatibility; it is not presented as verified physical carton identity. + */ + @Transactional + @BusinessAudit(event = "SMART_LOADING_INBOUND_ASSIGN", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "按入仓码分配智能装柜箱数") + public Plan assignInbound(long planId, AssignInboundRequest request, + long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' + + request.packageCount() + '|' + request.expectedVersion()); + Plan replay = replay(operation, planId, "ADD_INBOUND", null, requestHash); + if (replay != null) return replay; + + LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + List details = candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream() + .filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList(); + if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); + Candidate first = details.get(0); + if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) { + throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配"); + } + List allocations = proportionalAllocations(details, request.packageCount()); + int sequence = 0; + for (InboundAllocation allocation : allocations) { + if (allocation.packageCount() == 0) continue; + LoadingPlanEntity current = repository.plan(planId); + String childOperation = "INB-" + sha256(operation + '|' + allocation.candidate().inboundDetailId()) + .substring(0, 48); + assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), + allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName); + sequence++; + } + LoadingPlanEntity assigned = repository.plan(planId); + history(planId, null, "ADD_INBOUND", plan.getPlanStatus(), assigned.getPlanStatus(), operation, + json(values("version", request.expectedVersion(), "inboundId", request.inboundId())), + json(values("requestHash", requestHash, "packageCount", request.packageCount(), + "specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")), + operatorId, operatorName); + return view(assigned, booking); + } + + List proportionalAllocations(List details, int selected) { + int available = details.stream().map(Candidate::availablePackageCount).filter(Objects::nonNull) + .mapToInt(Integer::intValue).sum(); + if (selected < 1 || selected > available) throw validation("分配箱数超过入仓码可用箱数"); + if (selected == available) { + return details.stream().map(detail -> new InboundAllocation(detail, detail.availablePackageCount(), + BigDecimal.ZERO)).toList(); + } + List result = new ArrayList<>(); + int allocated = 0; + for (Candidate detail : details) { + int detailAvailable = Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount()); + BigDecimal exact = BigDecimal.valueOf(selected).multiply(BigDecimal.valueOf(detailAvailable)) + .divide(BigDecimal.valueOf(available), 12, RoundingMode.HALF_UP); + int base = exact.setScale(0, RoundingMode.DOWN).intValueExact(); + result.add(new InboundAllocation(detail, base, exact.subtract(BigDecimal.valueOf(base)))); + allocated += base; + } + int remaining = selected - allocated; + List priority = java.util.stream.IntStream.range(0, result.size()).boxed() + .sorted(Comparator.comparing(index -> result.get(index).remainder()).reversed() + .thenComparingInt(Integer::intValue)).toList(); + for (int index = 0; index < remaining; index++) { + int target = priority.get(index); + InboundAllocation current = result.get(target); + result.set(target, new InboundAllocation(current.candidate(), current.packageCount() + 1, + current.remainder())); + } + return result; + } + + record InboundAllocation(Candidate candidate, int packageCount, BigDecimal remainder) {} + @Transactional @BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "执行智能装柜推荐") diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 6fdb5dc6e..e893811e8 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -392,6 +392,17 @@ paths: - {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: {'200': {description: Candidates filtered by the server to the booking channel}} + /api/platform/v1/first-leg/smart-loading/inbound-candidates: + get: + summary: 按入仓码查询智能装柜候选 + description: >- + 需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行; + 平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。 + 这些数据用于数量级计划,不表示具体箱号已核验归属。 + parameters: + - {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: {'200': {description: 成功,data 为入仓码级候选列表}} /api/platform/v1/first-leg/smart-loading/plans/{planId}: get: summary: 查询智能装柜草稿详情 @@ -419,6 +430,20 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAssignRequest'}}} responses: '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments: + post: + summary: 按入仓码分配计划装柜箱数 + description: >- + 需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数; + 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单。 + 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundAssignRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} /api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}: patch: summary: 调整预装计划明细箱数 @@ -1599,6 +1624,14 @@ components: packageCount: {type: integer, minimum: 1} expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingInboundAssignRequest: + type: object + required: [inboundId, packageCount, expectedVersion, operationId] + properties: + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + packageCount: {type: integer, minimum: 1, description: 该入仓码本次计划装柜总箱数} + expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} + operationId: {type: string, minLength: 1, maxLength: 128} SmartLoadingAdjustRequest: type: object required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 10bdbfc48..4418f0d84 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -12,6 +12,7 @@ class PlatformSmartLoadingControllerContractTest { authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); authority("candidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class); + authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class); } @Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception { String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); @@ -21,6 +22,7 @@ class PlatformSmartLoadingControllerContractTest { } @Test void everyMutationDeclaresDedicatedPermission() throws Exception { authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); + authority("assignInbound","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignInboundRequest.class); authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class); authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class); @@ -41,6 +43,8 @@ class PlatformSmartLoadingControllerContractTest { String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); assertThat(yaml) .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:") + .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:") + .contains("SmartLoadingInboundAssignRequest:") .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:") .contains("SmartLoadingAssignRequest:") .contains("SmartLoadingAdjustRequest:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 554fa4435..a38e1799d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -182,6 +182,29 @@ class CarrierScheduleQueryServiceTest { verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); } + @Test + void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { + CarrierScheduleQueryEntity before = task(0, 3); + before.setStatus("READY"); before.setLatestStandardNode("LOADED_ON_VESSEL"); + before.setLatestNodeObservedAt(NOW.minusDays(1)); + CarrierScheduleQueryEntity after = task(0, 4); + after.setStatus("READY"); after.setLatestStandardNode("LOADED_ON_VESSEL"); + after.setLatestNodeObservedAt(NOW.minusDays(1)); + when(repository.lock(1L)).thenReturn(before); when(repository.find(1L)).thenReturn(after); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-estimated-1", + new ManualNodeRequest(3, "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(5), null, + "USLAX", "Los Angeles", "人工预计到港")); + + assertThat(result.query().query().version()).isEqualTo(4); + assertThat(result.query().query().latestActualNode()).isEqualTo("LOADED_ON_VESSEL"); + assertThat(result.query().query().latestActualAt()).isEqualTo(NOW.minusDays(1)); + verify(repository).updateState(before, 3, 7L, "admin"); + } + @Test void holdIsPersistedAsSeparateImmutableExceptionFact() { CarrierScheduleQueryEntity task = task(0, 1); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index a1974d345..c26f94ef7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -104,6 +104,49 @@ class SmartLoadingServiceTest { assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING"); } + @Test + void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() { + Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2")); + Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5")); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidates(100L)).thenReturn(List.of(first, second)); + + var result = service.inboundCandidates(99L, 100L); + + assertThat(result).singleElement().satisfies(candidate -> { + assertThat(candidate.inboundCode()).isEqualTo("I"); + assertThat(candidate.goodsSummary()).isEqualTo("服装、鞋类"); + assertThat(candidate.availablePackageCount()).isEqualTo(10); + assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000"); + assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000"); + assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2"); + }); + } + + @Test + void proportionallyDistributesInboundQuantityWithoutLosingCartons() { + Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2")); + Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5")); + Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L, + "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30, + new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8")); + + var result = service.proportionalAllocations(List.of(first, second, third), 60); + + assertThat(result).extracting(SmartLoadingService.InboundAllocation::packageCount) + .containsExactly(18, 24, 18); + assertThat(result).extracting(SmartLoadingService.InboundAllocation::candidate) + .extracting(Candidate::inboundDetailId).containsExactly(20L, 21L, 22L); + } + @Test void exposesPickedMinimumAndItemCapabilitiesForRunningTask() { LoadingPlanEntity plan = plan("LOADING", 3); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d7e5f736e..20752ba46 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -49,7 +49,7 @@ 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-customs.mjs`、`phase3-loading-finalize.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-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | -| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;仅登记 `containerRequirements`) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.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` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | @@ -57,7 +57,7 @@ export async function run(context) { | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-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` | `phase4-carrier-nodes-contract.test.mjs` | +| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index d89e29f2d..0e4c67a6c 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -2,20 +2,13 @@ const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' const OCEAN = '/api/platform/v1/ocean-bookings' -const PORT = '/api/platform/v1/port-schedules' +const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE']) import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs' function id(value) { return value == null ? '' : String(value) } -export function matchesManualScheduleScope(resource, expected) { - return Boolean(resource?.id) - && id(resource.customerId) === id(expected.customerId) - && id(resource.oceanBookingId) === id(expected.oceanBookingId) - && id(resource.containerBookingId) === id(expected.containerBookingId) -} function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } function localIso(value) { return value.toISOString().slice(0, 19) } -function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) } function step(context, name, response, passed, evidence) { context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed }) } @@ -39,12 +32,23 @@ async function request(context, method, path, body, headers = {}) { } } +export function ownsCarrierQuery(resource, marker) { + return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker) +} + +export function markerTrackingNo(marker, explicit = process.env.ADMIN_TEST_CARRIER_CONTAINER_NO) { + const configured = String(explicit || '').trim().toUpperCase() + if (configured) return configured + let hash = 0x811c9dc5 + for (const char of String(marker || 'carrier-marker')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) + return `TSTU${String(hash >>> 0).padStart(10, '0').slice(-7)}` +} + function oceanDraft(context, suffix, master) { return { ...oceanBookingDraft(context, suffix, master), - departurePortCode: 'CNSZX', - departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, @@ -56,115 +60,125 @@ function oceanDraft(context, suffix, master) { async function ensureOcean(context, suffix, master) { const existing = context.createdResources?.oceanBooking - if (existing?.id && id(existing.customerId) === id(master.customerId)) return { id: id(existing.id), containerBookingId: null } + if (existing?.id && existing.marker === context.marker && id(existing.customerId) === id(master.customerId)) { + return { id: id(existing.id), containerBookingId: null } + } const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, { 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS) + const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS) return { id: id(detail?.oceanBooking?.id), containerBookingId: null } } -async function createManualSchedule(context, suffix, oceanBookingId, customerId) { - const existing = context.createdResources?.portSchedule - const expectedScope = { customerId, oceanBookingId, containerBookingId: null } - if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id) - const body = { - expectedVersion: 0, - data: { - customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL', - sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`, - vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX', - departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60), bookingCutoffAt: at(7 * 24 * 60), - customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60), - observedAt: at(-1), expiresAt: at(3 * 24 * 60), remark: context.marker, - }, - reason: '船司官网人工查询兜底', - } - const response = await request(context, 'POST', PORT, body, - { 'Idempotency-Key': `${context.marker}-carrier-manual-schedule`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, '创建真实人工船期兜底结果', response, '', OPTIONS) - return id(detail?.portSchedule?.schedule?.id) +function nodeSnapshot(detail) { + return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({ + id: id(item.id), providerCode: item.providerCode, providerEventId: item.providerEventId, + standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt, + })) } export async function run(context) { - context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询', - 'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键', - 'external-third-party') context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', - 'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功', + 'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF', 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) const carriersResponse = await request(context, 'GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS) const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) - context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse, + context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) - && byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true - && byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' - && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true + && AUTOMATED_CARRIERS.every((code) => byCode.get(code)?.automated === true + && byCode.get(code)?.coverageGap === false) + && byCode.get('COSCO')?.automated === false && byCode.get('COSCO')?.coverageGap === true + && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' - && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false), - `codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS) + && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')), + `automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS) const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const ocean = await ensureOcean(context, suffix, master) if (!ocean.id) return + const trackingNo = markerTrackingNo(context.marker) const createBody = { expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id, - containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'BILL_OF_LADING', - trackingNo: `${context.marker}-ZIM`, remark: context.marker, + containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', + trackingNo, remark: context.marker, } const createKey = `${context.marker}-carrier-query-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - const created = context.apiSuccess(MODULE, '创建真实船司查询任务', createResponse, '', OPTIONS) + const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS) const queryId = id(created?.query?.query?.id) - context.assert(MODULE, '查询任务初始来源和过期字段稳定', createResponse, + context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse, Boolean(queryId) && created?.query?.query?.status === 'READY' - && created?.query?.expired === true && created?.query?.query?.accessMode === 'OFFICIAL_API', - `queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS) + && created?.query?.query?.carrierCode === 'ZIM' + && created?.query?.query?.trackingType === 'CONTAINER_NO' + && created?.query?.query?.trackingNo === trackingNo, + `queryId=${queryId || 'missing'}, trackingNo=${created?.query?.query?.trackingNo || 'missing'}`, OPTIONS) if (!queryId) return + context.createdResources.phase3CarrierScheduleQuery = { + queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId), + oceanBookingId: ocean.id, trackingNo, + } const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) - context.assert(MODULE, '创建任务幂等重放返回同一资源', replay, + context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay, id(data(replay)?.query?.query?.id) === queryId, `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) - const version = Number(created.query.query.version) - const unknownCarrier = await request(context, 'POST', BASE, - { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` }, + { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${trackingNo}U` }, { 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) }) step(context, '非法船司代码被拒绝', unknownCarrier, unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)), `HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`) - const forgedCustomer = await request(context, 'POST', BASE, - { ...createBody, customerId: '10002', trackingNo: `${context.marker}-FORGED` }, - { 'Idempotency-Key': `${context.marker}-carrier-forged-customer`.slice(0, 128) }) - step(context, '伪造客户与订舱关联被拒绝', forgedCustomer, - forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)), - `HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`) + const initialTask = created.query.query + const observedAt = localIso(new Date()) + const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000)) + const manualBody = { + expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT', + observedAt, estimatedAt, actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles', + reason: `本轮marker预计节点验收 ${context.marker}`, + } + const manualKey = `${context.marker}-carrier-manual-node`.slice(0, 128) + const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, + { 'Idempotency-Key': manualKey }) + const manualDetail = context.apiSuccess(MODULE, '仅向本轮marker任务补录预计节点', manual, '', OPTIONS) + const manualTask = manualDetail?.query?.query + context.assert(MODULE, '预计事实可见、版本前进且实际进度零推进', manual, + id(manualTask?.id) === queryId && Number(manualTask?.version) > Number(initialTask.version) + && manualTask?.status === initialTask.status + && manualTask?.latestActualNode === initialTask.latestActualNode + && manualTask?.latestActualAt === initialTask.latestActualAt + && manualDetail?.nodes?.some((item) => item.providerCode === 'MANUAL' + && item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null), + `version=${manualTask?.version ?? 'missing'}, latest=${manualTask?.latestActualNode || 'none'}`, OPTIONS) - const scheduleId = await createManualSchedule(context, suffix, ocean.id, master.customerId) - if (!scheduleId) return - const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`, - { expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' }, - { 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) }) - const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS) - context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, - completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId - && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) - && completed?.query?.expired === false - && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), - `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS) + const replayManual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, + { 'Idempotency-Key': manualKey }) + const replayDetail = context.apiSuccess(MODULE, '人工节点同key同payload幂等重放', replayManual, '', OPTIONS) + context.assert(MODULE, '人工节点重放版本和事实集合不漂移', replayManual, + Number(replayDetail?.query?.query?.version) === Number(manualTask?.version) + && JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(manualDetail)), + `version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS) + + const stale = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody, + { 'Idempotency-Key': `${context.marker}-carrier-manual-stale`.slice(0, 128) }) + step(context, '旧version配合新幂等键被A0409拒绝', stale, + stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)), + `HTTP=${stale.http}, code=${codeOf(stale)}`) const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) - const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS) - context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse, - !Object.hasOwn(detail || {}, 'attempts') + const detail = context.apiSuccess(MODULE, '回查本轮marker任务零漂移', detailResponse, '', OPTIONS) + context.assert(MODULE, '并发拒绝后版本、实际进度和事实集合零漂移', detailResponse, + Number(detail?.query?.query?.version) === Number(manualTask?.version) + && detail?.query?.query?.latestActualNode === manualTask?.latestActualNode + && detail?.query?.query?.latestActualAt === manualTask?.latestActualAt + && JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(manualDetail)) + && !Object.hasOwn(detail || {}, 'attempts') && detail?.histories?.some((item) => item.action === 'CREATE') - && detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'), - `attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) + && detail?.histories?.some((item) => item.action === 'MANUAL_NODE'), + `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) + context.createdResources.phase3CarrierScheduleQuery.version = Number(manualTask?.version) } diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 70155c9d1..1b4587d7e 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -1,24 +1,47 @@ 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 { matchesManualScheduleScope } from './phase3-carrier-schedule-queries.mjs' +import { markerTrackingNo, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' -const expected = { customerId: '10001', oceanBookingId: '20001', containerBookingId: null } +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8') -test('does not reuse a schedule from the same booking when the container scope differs', () => { - assert.equal(matchesManualScheduleScope({ - id: '30001', customerId: '10001', oceanBookingId: '20001', containerBookingId: '40001', - }, expected), false) +test('marker ownership requires explicit module ownership and the exact current marker', () => { + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: true }, 'M'), true) + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true }, 'M'), false) + assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: false }, 'M'), false) + assert.equal(ownsCarrierQuery({ marker: 'M', ownedByModule: true }, 'M'), false) }) -test('does not reuse a schedule whose customer scope is unknown', () => { - assert.equal(matchesManualScheduleScope({ - id: '30001', oceanBookingId: '20001', containerBookingId: null, - }, expected), false) +test('default tracking number is deterministic marker data while explicit samples are opt-in', () => { + const first = markerTrackingNo('RUN-20260821-001', '') + const second = markerTrackingNo('RUN-20260821-001', '') + assert.equal(first, second) + assert.match(first, /^TSTU\d{7}$/) + assert.equal(markerTrackingNo('RUN', ' zcsu-test-only '), 'ZCSU-TEST-ONLY') }) -test('reuses a schedule only when customer, booking and container scopes all match', () => { - assert.equal(matchesManualScheduleScope({ - id: '30001', customerId: 10001, oceanBookingId: 20001, containerBookingId: null, - }, expected), true) +test('phase3 contract creates and mutates only its marker-owned container query', () => { + assert.match(source, /carrierCode:\s*'ZIM',\s*trackingType:\s*'CONTAINER_NO'/) + assert.match(source, /phase3CarrierScheduleQuery\s*=\s*\{[\s\S]*queryId,[\s\S]*marker:\s*context\.marker,[\s\S]*ownedByModule:\s*true/) + assert.match(source, /\/manual-nodes/) + assert.match(source, /'Idempotency-Key'/) + assert.match(source, /expectedVersion:\s*Number\(initialTask\.version\)/) + assert.match(source, /actualAt:\s*null/) + assert.match(source, /A0409/) + assert.match(source, /nodeSnapshot\(detail\)[\s\S]*nodeSnapshot\(manualDetail\)/) + assert.doesNotMatch(source, /\/manual-results/) + assert.doesNotMatch(source, /\/attempts|\/scheduler|\/internal|\/pda/) + assert.doesNotMatch(source, /BILL_OF_LADING/) + assert.doesNotMatch(source, /createManualSchedule|portScheduleId|\/api\/platform\/v1\/port-schedules/) +}) + +test('carrier capability assertion freezes five automated providers and COSCO only gap', () => { + assert.match(source, /\['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'\]/) + assert.match(source, /AUTOMATED_CARRIERS\.every[\s\S]*automated === true[\s\S]*coverageGap === false/) + assert.match(source, /byCode\.get\('COSCO'\)\?\.automated === false/) + assert.match(source, /OFFICIAL_API_CREDENTIALS_REQUIRED/) }) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 2cd67e92a..69c645794 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -4,17 +4,17 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' +import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8') function response(data) { return { http: 200, body: { code: '200', data } } } -function contextFor(routes) { +function contextFor(routes, createdResources = {}) { const calls = [] return { calls, - marker: 'T-PHASE4', token: 'token', + marker: 'T-PHASE4', token: 'token', createdResources, request: async (method, requestPath, body, _token, headers = {}) => { calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath }) assert.ok(['GET', 'POST'].includes(method)) @@ -84,6 +84,19 @@ test('canonical task node and exception sets are locked to the phase-4 contract' assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) }) +test('marker ownership requires explicit module ownership, query id and exact current marker', () => { + assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {} }), null) + assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { + phase3CarrierScheduleQuery: { queryId: '1', marker: 'OLD', ownedByModule: true }, + } }), null) + assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { + phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false }, + } }), null) + assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: { + phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: true }, + } }).queryId, '1') +}) + test('empty page validates read-only pagination without detail calls', async () => { const context = contextFor({ '/api/platform/v1/carrier-schedule-queries/carriers': carriers, @@ -96,17 +109,40 @@ test('empty page validates read-only pagination without detail calls', async () ]) }) -test('existing task reads page, detail and history with nullable node times', async () => { +test('standalone existing historical task is read-only and never receives manual nodes', async () => { const base = '/api/platform/v1/carrier-schedule-queries' const context = contextFor({ [`${base}/carriers`]: carriers, [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, - [`${base}/${task.id}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] }, [`${base}/${task.id}/history`]: [history], }) await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) - assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`) + assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET']) + assert.equal(context.calls.some((item) => item.method === 'POST'), false) assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`) }) + +test('phase4 writes only the exact current-marker query registered by phase3', async () => { + const base = '/api/platform/v1/carrier-schedule-queries' + const ownedTask = { ...task, id: '9007199254740994', queryNo: 'CSQ9007199254740994', + status: 'READY', trackingNo: 'TSTU1234567', version: 6 } + const resource = { queryId: ownedTask.id, marker: 'T-PHASE4', ownedByModule: true, trackingNo: ownedTask.trackingNo } + const context = contextFor({ + [`${base}/carriers`]: carriers, + [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, + [`${base}/${ownedTask.id}`]: { query: { query: ownedTask, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, + [`${base}/${ownedTask.id}/manual-nodes`]: { query: { query: { ...ownedTask, version: 7 }, expired: false }, + nodes: [node, manualNode], exceptions: [exception], histories: [history] }, + [`${base}/${ownedTask.id}/history`]: [history], + }, { phase3CarrierScheduleQuery: resource }) + + await run(context) + + assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) + const write = context.calls.find((item) => item.method === 'POST') + assert.equal(write.path, `${base}/${ownedTask.id}/manual-nodes`) + assert.equal(write.body.expectedVersion, ownedTask.version) + assert.equal(write.body.actualAt, null) + assert.equal(write.headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${ownedTask.id}`) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 14d16cc54..a9c17d7de 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -16,6 +16,12 @@ function nullableText(value) { return value == null || typeof value === 'string' function nullableId(value) { return value == null || Boolean(id(value)) } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } +export function markerOwnedCarrierQuery(context) { + const resource = context?.createdResources?.phase3CarrierScheduleQuery + return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker + ? resource : null +} + function validTask(task) { return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) && QUERY_STATUSES.includes(task?.status) @@ -73,14 +79,15 @@ export async function run(context) { && records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)), `records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS) - if (records.length === 0) { + const ownedResource = markerOwnedCarrierQuery(context) + if (records.length === 0 && !ownedResource) { context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse, Number(page?.total) === 0 && Number(page?.pages) === 0, `records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS) return } - const queryId = id(records[0]?.query?.id) + const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id) const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] @@ -94,6 +101,20 @@ export async function run(context) { `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) const task = detail.query.query + if (!ownedResource) { + context.assert(MODULE, 'standalone无本轮marker资源时严格只读并跳过人工写', detailResponse, + true, `readOnlyQueryId=${queryId}`, OPTIONS) + const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) + const history = context.apiSuccess(MODULE, '只读查询历史任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) + context.assert(MODULE, 'standalone历史详情只读且结构稳定', historyResponse, + Array.isArray(history) && history.every(validHistory), + `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) + return + } + const ownershipMatches = id(task?.id) === queryId && task?.trackingNo === ownedResource.trackingNo + context.assert(MODULE, 'phase4写入目标严格匹配本轮phase3登记', detailResponse, + ownershipMatches, `registered=${queryId}, actual=${id(task?.id) || 'missing'}`, OPTIONS) + if (!ownershipMatches) return const observedAt = task.latestActualAt || '2026-08-21T00:00:00' const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, { expectedVersion: task.version,