From 82ee80226e8fb92d506f6e3f38d74bc95d5171d0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 18 Aug 2026 16:03:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/FirstLegLoadingPlanCompletedV2.java | 25 ++ .../PlatformSmartLoadingController.java | 2 +- .../baiyue/firstleg/dto/LoadingListDtos.java | 3 +- .../mapper/LoadingCompletedEventMapper.java | 2 + .../mapper/LoadingListQueryMapper.java | 3 +- .../mapper/OceanBookingMapper.java | 39 -- .../repository/OceanBookingRepository.java | 9 - .../LoadingCompletedOutboxService.java | 21 +- .../main/resources/openapi/firstleg-v1.yaml | 23 ++ ...ormSmartLoadingControllerContractTest.java | 3 + .../LoadingCompletedOutboxServiceTest.java | 3 +- .../service/SmartLoadingServiceTest.java | 30 +- .../InternalOrderCargoControllerTest.java | 10 + .../ocean-booking-fixture.mjs | 7 +- .../admin-test-env-modules/ocean-bookings.mjs | 335 +++++------------- 15 files changed, 196 insertions(+), 319 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java new file mode 100644 index 000000000..032cc427c --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -0,0 +1,25 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.util.List; + +/** 装柜完成交接快照,包含三类逐货物备注。 */ +public record FirstLegLoadingPlanCompletedV2( + String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, String warehouseName, + List orders) { + public record Order(String sourceInboundId, String rootInboundId, String sourceOrderId, + String sourceOrderNo, String rootOrderId, String inboundCode, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int packageCount, Integer productQuantity, + Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal volumeCbm, List items) {} + public record Item(String sourceInboundDetailId, String skuCode, String productNameZh, + String productNameEn, int packageCount, Integer productQuantity, + Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, String shipmentNo, + String shippingMark, String customerRemark, String internalRemark, + String warehouseRemark) {} +} 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 d018e4cd8..0560e482f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -22,7 +22,7 @@ public class PlatformSmartLoadingController { 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')") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));} - @PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));} @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 230523bc1..d7b45a413 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -192,7 +192,8 @@ public final class LoadingListDtos { String customerRemark, String warehouseRemark, int loadSequence, - LocalDateTime frozenAt + LocalDateTime frozenAt, + Integer itemVersion ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index d464a8d59..06cd7e422 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -25,6 +25,7 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark + ,i.customer_remark, i.internal_remark, i.warehouse_remark FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 @@ -50,5 +51,6 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; + private String customerRemark; private String internalRemark; private String warehouseRemark; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index aead11b74..c93a7c73d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -178,7 +178,7 @@ public interface LoadingListQueryMapper { gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg, product_quantity, units_per_carton, fba_tracking_no, shipment_no, shipping_mark, internal_remark, customer_remark, warehouse_remark, - load_sequence, frozen_at + load_sequence, frozen_at, i.version AS item_version FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' @@ -367,6 +367,7 @@ public interface LoadingListQueryMapper { private String warehouseRemark; private Integer loadSequence; private LocalDateTime frozenAt; + private Integer itemVersion; } @Getter diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 2c8a63e2a..62789c794 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -10,7 +10,6 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; -import java.time.LocalDateTime; @Mapper public interface OceanBookingMapper extends BaseMapper { @@ -53,42 +52,4 @@ public interface OceanBookingMapper extends BaseMapper { @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - @Update(""" - UPDATE fl_ocean_booking SET status='RELEASED',bill_of_lading_no=#{billOfLadingNo}, - vessel_name=#{vesselName},voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt}, - estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion} - """) - int release(@Param("oceanBookingId") long oceanBookingId, @Param("billOfLadingNo") String billOfLadingNo, - @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, - @Param("estimatedSailingAt") LocalDateTime estimatedSailingAt, - @Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt, - @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET status='CHANGE_PENDING',pending_vessel_name=#{vesselName}, - pending_voyage_no=#{voyageNo},pending_estimated_sailing_at=#{estimatedSailingAt}, - pending_estimated_arrival_at=#{estimatedArrivalAt},change_reason=#{reason}, - updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion} - """) - int requestChange(@Param("oceanBookingId") long oceanBookingId, @Param("vesselName") String vesselName, - @Param("voyageNo") String voyageNo, @Param("estimatedSailingAt") LocalDateTime estimatedSailingAt, - @Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt, @Param("reason") String reason, - @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_ocean_booking SET status='CONFIRMED',vessel_name=pending_vessel_name,voyage_no=pending_voyage_no, - estimated_sailing_at=pending_estimated_sailing_at,estimated_arrival_at=pending_estimated_arrival_at, - pending_vessel_name=NULL,pending_voyage_no=NULL,pending_estimated_sailing_at=NULL, - pending_estimated_arrival_at=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName}, - updated_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{oceanBookingId} AND status='CHANGE_PENDING' AND version=#{expectedVersion} - """) - int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index e4ab3831a..b93da0f32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -47,15 +47,6 @@ public class OceanBookingRepository { histories.delete(new LambdaQueryWrapper().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id)); return bookings.deleteDraft(id, version); } - public int requestChange(long id, OceanBookingEntity pending, String reason, int version, - long operatorId, String operatorName) { - return bookings.requestChange(id, pending.getPendingVesselName(), pending.getPendingVoyageNo(), - pending.getPendingEstimatedSailingAt(), pending.getPendingEstimatedArrivalAt(), reason, - version, operatorId, operatorName); - } - public int confirmChange(long id, int version, long operatorId, String operatorName) { - return bookings.confirmChange(id, version, operatorId, operatorName); - } public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 396341a2b..5fc9bc31a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -8,7 +8,7 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2; import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; @@ -33,7 +33,7 @@ import java.util.Map; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service public class LoadingCompletedOutboxService { - private static final long HANDOVER_VERSION = 1L; + private static final long HANDOVER_VERSION = 2L; @Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; @@ -50,32 +50,33 @@ public class LoadingCompletedOutboxService { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); - List orders = byInbound.values().stream() + List orders = byInbound.values().stream() .map(this::order).toList(); - var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()), + var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); JsonNode payloadNode = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String traceId = EventIdentifiers.newUuid(); - return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, + return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 2, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN", String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV1.Order order(List values) { + private FirstLegLoadingPlanCompletedV2.Order order(List values) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); - List items = sorted.stream().map(row -> - new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), + List items = sorted.stream().map(row -> + new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), - row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark())) + row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), + row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark())) .toList(); - return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()), + return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()), string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 57e9ba00b..7beb6e457 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -405,6 +405,18 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}} responses: '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark: + patch: + summary: 修改装柜明细仓库备注 + description: EDITING/LOADING 可修改或清空 warehouseRemark;COMPLETED、计划/明细版本冲突返回 A0409,写入历史且与 PDA final complete 使用同一并发门禁。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} /api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload: post: summary: 推送智能装柜计划到预装清单 @@ -1531,6 +1543,14 @@ components: expectedPlanVersion: {type: integer, minimum: 0} expectedItemVersion: {type: integer, minimum: 0} operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingWarehouseRemarkRequest: + type: object + required: [expectedPlanVersion, expectedItemVersion, operationId] + properties: + warehouseRemark: {type: string, nullable: true, maxLength: 1000, description: 仓库备注;null/空字符串均表示清空} + expectedPlanVersion: {type: integer, minimum: 0} + expectedItemVersion: {type: integer, minimum: 0} + operationId: {type: string, minLength: 1, maxLength: 128} SmartLoadingPlan: type: object required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] @@ -1562,6 +1582,9 @@ components: orderNo: {type: string, nullable: true} inboundCode: {type: string, nullable: true} customerName: {type: string, nullable: true} + customerRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单 customerRemark} + internalRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单最新内部备注,不使用 cargoRemark} + warehouseRemark: {type: string, nullable: true, description: 仓库人员逐条维护,可在 EDITING/LOADING 修改} packageCount: {type: integer, minimum: 1} version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值} pickedPackageCount: {type: integer, minimum: 0} 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 fe8926a2c..709246390 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -23,6 +23,7 @@ class PlatformSmartLoadingControllerContractTest { authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.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:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class); authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class); } @@ -42,6 +43,8 @@ class PlatformSmartLoadingControllerContractTest { .contains("SmartLoadingAssignRequest:") .contains("SmartLoadingAdjustRequest:") .contains("SmartLoadingDeleteRequest:") + .contains("SmartLoadingWarehouseRemarkRequest:") + .contains("/items/{itemId}/warehouse-remark:") .contains("canAssign: {type: boolean}") .contains("canEdit: {type: boolean}") .contains("pickedPackageCount: {type: integer, minimum: 0}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 9a0e44baa..6b480b998 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -46,7 +46,8 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().get("orders")).hasSize(2); assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); - assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1"); + assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); + assertThat(first.eventVersion()).isEqualTo(2); } @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { 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 8e44c9ca3..7612471c5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; @@ -237,7 +238,7 @@ class SmartLoadingServiceTest { when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success( new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", - 4, 100, new BigDecimal("10"), "货物备注"))); + 4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注"))); doAnswer(invocation -> { Object value = invocation.getArgument(0); if (value instanceof LoadingPlanItemEntity item) item.setId(88L); @@ -255,7 +256,9 @@ class SmartLoadingServiceTest { assertThat(saved.getUnitsPerCarton()).isEqualTo(4); assertThat(saved.getProductQuantity()).isEqualTo(20); assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5"); - assertThat(saved.getInternalRemark()).isEqualTo("货物备注"); + assertThat(saved.getCustomerRemark()).isEqualTo("客户备注"); + assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注"); + assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注"); ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); verify(repository).insert(historyCaptor.capture()); @@ -378,6 +381,29 @@ class SmartLoadingServiceTest { verify(repository).touch(1, 2, 7, "u"); } + @Test + void updatesAndClearsWarehouseRemarkWithBothVersions() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 1); + line.setWarehouseRemark("旧仓库备注"); + when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); + when(repository.item(1L, 8L)).thenReturn(line); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(line)); + when(repository.updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u")).thenReturn(1); + + Plan result = service.updateWarehouseRemark(1L, 8L, + new WarehouseRemarkRequest(null, 2, 0, "warehouse-clear"), 7L, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(loadingPickService).lockMutable(1L); + verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u"); + ArgumentCaptor history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(history.capture()); + assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK"); + assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注"); + } + @Test void preservesDownstreamCode() { stubAssignment(); diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 8877beb4c..97be8050c 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -44,6 +45,10 @@ class InternalOrderCargoControllerTest { cargo.setNetWeightKg(new java.math.BigDecimal("18.500")); cargo.setCargoRemark("内部货物备注"); when(orders.findCargoInheritedByOrder(11L, 21L, 31L)).thenReturn(cargo); + OrderEntity order = new OrderEntity(); order.setId(21L); order.setCustomerRemark("客户订单备注"); + OrderInternalNoteEntity note = new OrderInternalNoteEntity(); note.setContent("最新内部备注"); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findNotes(11L, 21L)).thenReturn(List.of(note)); var response = controller.detail("internal-token", 11L, 21L, 31L); @@ -56,6 +61,8 @@ class InternalOrderCargoControllerTest { assertThat(response.data().totalQuantity()).isEqualTo(200); assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500"); assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注"); + assertThat(response.data().customerRemark()).isEqualTo("客户订单备注"); + assertThat(response.data().latestInternalNote()).isEqualTo("最新内部备注"); verify(orders).findCargoInheritedByOrder(11L, 21L, 31L); } @@ -99,6 +106,9 @@ class InternalOrderCargoControllerTest { OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); cargo.setOrderId(21L); when(orders.findCargoInheritedByOrder(11L, 201L, 31L)).thenReturn(cargo); + OrderEntity child = new OrderEntity(); child.setId(201L); + when(orders.findOwned(11L, 201L)).thenReturn(child); + when(orders.findNotes(11L, 201L)).thenReturn(List.of()); var response = controller.detail("internal-token", 11L, 201L, 31L); diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index e95afc905..13b80411c 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -55,9 +55,8 @@ export async function resolveOceanBookingMaster(context, module) { export function oceanBookingDraft(context, suffix, master, labels = {}) { const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING' - const containers = labels.containers || [{ - containerNo: `TST-${normalizedSuffix}-01`.slice(0, 64), - containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, + const containerRequirements = labels.containerRequirements || [{ + containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000, }] return { ...writableMaster, @@ -75,7 +74,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], - containers, + containerRequirements, } } diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index a55cfce00..7c17d5d9d 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -1,269 +1,102 @@ const MODULE = 'ocean-bookings' const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/ocean-bookings' -import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' +import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs' function id(value) { return value == null ? '' : String(value) } -function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) } -function missing(r) { return r?.http === 404 || r?.http === 405 || r?.http === 503 } -function failure(r) { return [400, 401, 403, 404, 409, 503].includes(r?.http) || (r?.http === 200 && codeOf(r) !== '200') } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function payload(response) { return codeOf(response) === '200' ? response.body.data : null } +function failed(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } async function request(context, method, path, body, headers = {}) { try { - const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) }) - const raw = await response.text(); let payload = null - try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } + const response = await fetch(`${context.options.gateway}${path}`, { + method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let parsed = null + try { parsed = raw ? JSON.parse(raw) : null } catch { /* retain bounded raw evidence */ } + return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null, + requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.traceId || response.headers.get('x-trace-id') } } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } -function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } -async function createBooking(context, suffix, key, master, bookingAgent) { - const containers = [ - { containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }, - { containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 }, - ] - return request(context, 'POST', BASE, - { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { - containers, - bookingAgentId: bookingAgent.supplierId, - bookingAgentName: bookingAgent.name, - }) }, - { 'Idempotency-Key': key }) +function step(context, name, response, pass, evidence = '') { context.addStep(MODULE, name, response, pass, evidence, OPTIONS) } +function fullDraft(context, suffix, master, labels) { + return oceanBookingDraft(context, suffix, master, { + ...labels, + containerRequirements: labels.containerRequirements || [ + { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }, + { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + ], + }) } -function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } - -async function createConfirmedUnassignedBooking(context, suffix, master) { - const draft = oceanBookingDraftWithoutCustomer(context, `${suffix}U`, master) - const key = `${context.marker}-ocean-unassigned-create`.slice(0, 128) - const createdResponse = await request(context, 'POST', BASE, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': key }) - const created = data(createdResponse) - const bookingId = id(created?.oceanBooking?.id) - const createdBookingNo = id(created?.oceanBooking?.bookingNo) - context.apiSuccess(MODULE, '创建未指定客户订舱', createdResponse, - `marker=${context.marker}, customerFields=${'customerId' in draft || 'customerName' in draft ? 'present' : 'omitted'}`, OPTIONS) - context.assert(MODULE, '未指定客户订舱不伪造客户快照', createdResponse, - Boolean(bookingId) && created?.oceanBooking?.customerId == null && created?.oceanBooking?.customerName == null, - `bookingId=${bookingId || 'missing'}, customerId=${id(created?.oceanBooking?.customerId) || 'null'}, customerName=${created?.oceanBooking?.customerName || 'null'}`, OPTIONS) - if (!bookingId) return null - - const path = `${BASE}/${bookingId}` - const detailResponse = await request(context, 'GET', path) - const detail = context.apiSuccess(MODULE, '回查未指定客户订舱持久化结果', detailResponse, '', OPTIONS) - const bookingNo = id(detail?.oceanBooking?.bookingNo || created?.oceanBooking?.bookingNo) - context.assert(MODULE, '未指定客户订舱保持空客户且原子生成一条真实柜', detailResponse, - detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null - && detail?.oceanBooking?.customerName == null && detail?.containers?.length === 1 && Boolean(bookingNo), - `status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, containers=${detail?.containers?.length ?? 'n/a'}`, OPTIONS) - - const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`) - const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS) - context.assert(MODULE, '未指定客户订舱可按真实订舱号从正式列表查询', listResponse, - page?.records?.some?.((item) => id(item.id) === bookingId && item.customerId == null && item.customerName == null), - `bookingId=${bookingId}, bookingNo=${bookingNo}, records=${page?.records?.length ?? 'n/a'}`, OPTIONS) - const resource = { id: bookingId, marker: context.marker, customerId: null, customerName: null, - bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, - containerBookingId: id(detail?.containers?.[0]?.containerBookingId) || null, - finalStatus: detail?.oceanBooking?.status || null } - context.createdResources.oceanBookingUnassignedCustomer = resource - return resource -} - -async function releaseLoadingBooking(context, containerFlow, suffix) { - const flowOceanBookingId = id(containerFlow?.oceanBookingId) - if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null - const flowPath = `${BASE}/${flowOceanBookingId}` - let flowResponse = await request(context, 'GET', flowPath) - let flow = data(flowResponse) - context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, - `oceanBookingId=${flowOceanBookingId}`, OPTIONS) - const allocationMatches = flow?.containers?.some?.((item) => - id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, - allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), - `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, containers=${flow?.containers?.length ?? 'n/a'}`, OPTIONS) - if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { - const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] - const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, flow?.oceanBooking?.[field]])) - const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') - context.assert(MODULE, '实际装货柜次订舱回读完整冻结运输快照', flowResponse, frozenTransportValid, - `oceanBookingId=${flowOceanBookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) - if (!frozenTransportValid) return null - const flowRelease = await request(context, 'POST', `${flowPath}/release`, { - expectedVersion: Number(flow.oceanBooking.version), ...frozenTransport, - reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) - context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) - flowResponse = await request(context, 'GET', flowPath) - flow = data(flowResponse) - } - context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, - flow?.oceanBooking?.status === 'RELEASED' - && flow?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), - `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) - return flow?.oceanBooking?.status === 'RELEASED' && allocationMatches ? { - id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, - containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), - finalStatus: 'RELEASED', - } : null +function transportSnapshot(value) { + return ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + .reduce((out, key) => { out[key] = value?.[key]; return out }, {}) } +function hasTransport(value) { return Object.values(transportSnapshot(value)).every((v) => v != null && String(v).trim() !== '') } export async function run(context) { - const containerFlow = context.createdResources?.phase3Loading - || context.createdResources?.phase3ContainerLoading - const suffix = context.marker.replace(/\D/g, '').slice(-10) - const existing = context.createdResources?.oceanBooking - if (existing?.marker === context.marker && existing?.finalStatus === 'RELEASED') { - if (!id(containerFlow?.containerBookingId) - || id(existing.containerBookingId) === id(containerFlow.containerBookingId)) return - const reconciled = await releaseLoadingBooking(context, containerFlow, suffix) - if (reconciled) context.createdResources.oceanBooking = reconciled - return - } const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return - const bookingAgentResponse = await request(context, 'GET', - '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') - const bookingAgentPage = context.apiSuccess(MODULE, '查询启用的订舱供应商', bookingAgentResponse, '', OPTIONS) - const bookingAgent = bookingAgentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') - context.assert(MODULE, '订舱供应商不区分国内或国外', bookingAgentResponse, - Boolean(bookingAgent?.id && bookingAgent?.name) && !Object.hasOwn(bookingAgent || {}, 'scope'), - `supplierId=${id(bookingAgent?.id) || 'missing'}, scopeExposed=${Object.hasOwn(bookingAgent || {}, 'scope')}`, OPTIONS) - if (!bookingAgent?.id || !bookingAgent?.name || Object.hasOwn(bookingAgent, 'scope')) return - const bookingAgentSnapshot = { supplierId: id(bookingAgent.id), name: bookingAgent.name } - const list = await request(context, 'GET', `${BASE}?page=1&size=5`) - if (missing(list) || list.http === 0) { - step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`) - return - } - const page = context.apiSuccess(MODULE, '订舱列表分页查询', list, '', OPTIONS) - context.assert(MODULE, '订舱列表分页结构正确', list, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS) - const duplicateNo = `TST-${suffix}-DUP`.slice(0, 64) - const duplicateDraft = oceanBookingDraft(context, `${suffix}D`, master, { containers: [ - { containerNo: duplicateNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }, - { containerNo: duplicateNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }, - ] }) - const duplicateCreate = await request(context, 'POST', BASE, { expectedVersion: 0, data: duplicateDraft }, - { 'Idempotency-Key': `${context.marker}-ocean-duplicate-in-request`.slice(0, 128) }) - step(context, '同一订舱请求重复真实柜号原子拒绝', duplicateCreate, failure(duplicateCreate), - `containerNo=${duplicateNo}, HTTP=${duplicateCreate.http}, code=${codeOf(duplicateCreate)}`) - const duplicateListResponse = await request(context, 'GET', `${BASE}?page=1&size=5`) - const duplicatePage = context.apiSuccess(MODULE, '回查请求内重复柜号失败后的订舱列表', duplicateListResponse, '', OPTIONS) - const duplicateContainerResponse = await request(context, 'GET', - `/api/platform/v1/containers?page=1&size=20&keyword=${encodeURIComponent(duplicateNo)}`) - const duplicateContainers = context.apiSuccess(MODULE, '回查请求内重复柜号失败后的货柜列表', duplicateContainerResponse, '', OPTIONS) - context.assert(MODULE, '请求内重复柜号失败后订舱和货柜零写入', duplicateContainerResponse, - Number(duplicatePage?.total) === Number(page?.total) - && Array.isArray(duplicateContainers?.records) && duplicateContainers.records.length === 0, - `bookingTotalBefore=${page?.total ?? 'n/a'}, bookingTotalAfter=${duplicatePage?.total ?? 'n/a'}, containers=${duplicateContainers?.records?.length ?? 'n/a'}`, OPTIONS) - const statuses = ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'] - for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) } - for (const status of ['DRAFT', 'SUBMITTED']) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); step(context, `正式订舱列表拒绝 ${status} 状态`, r, failure(r), `HTTP=${r.http}, code=${codeOf(r)}`) } - - await createConfirmedUnassignedBooking(context, suffix, master) - - const key = `${context.marker}-ocean-create` - const createResponse = await createBooking(context, suffix, key, master, bookingAgentSnapshot) - const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id) - context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS) - context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse, - Boolean(bookingId) && detail?.oceanBooking?.status === 'CONFIRMED' - && detail?.parties?.length === 3 && detail?.containers?.length === 2 - && ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] - .every((field) => Boolean(detail?.oceanBooking?.[field])), - `bookingId=${bookingId || 'missing'}, status=${detail?.oceanBooking?.status || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, containers=${detail?.containers?.length ?? 'n/a'}`, OPTIONS) - context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse, - id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName, - `expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS) - context.assert(MODULE, '订舱持久化启用订舱供应商快照', createResponse, - id(detail?.oceanBooking?.bookingAgentId) === bookingAgentSnapshot.supplierId - && detail?.oceanBooking?.bookingAgentName === bookingAgentSnapshot.name, - `expected=${bookingAgentSnapshot.supplierId}/${bookingAgentSnapshot.name}, actual=${id(detail?.oceanBooking?.bookingAgentId) || 'missing'}/${detail?.oceanBooking?.bookingAgentName || 'missing'}`, OPTIONS) - const expectedContainerNos = [`TST-${suffix}-01`.slice(0, 64), `TST-${suffix}-02`.slice(0, 64)] - context.assert(MODULE, '一单两柜逐柜原子生成独立需求分配和 ACTIVE 操作身份', createResponse, - detail?.containers?.length === 2 && expectedContainerNos.every((containerNo) => { - const item = detail.containers.find((value) => value.containerNo === containerNo) - return Boolean(item?.containerBookingId && item?.cabinetNo && item?.requirementId && item?.allocationId) - && id(item?.warehouseId) === id(master.warehouseId) && item?.channelCode === master.channelCode - && id(item?.customerId) === id(master.customerId) - }), - `expected=${expectedContainerNos.join('|')}, actual=${detail?.containers?.map?.((item) => item.containerNo).join('|') || 'none'}`, OPTIONS) + const suffix = context.marker.replace(/\D/g, '').slice(-10) || 'BOOKING' + const agentResponse = await request(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const agentPage = payload(agentResponse) + const agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') + step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name), + `supplierId=${id(agent?.id) || 'missing'}, scope=${agent?.scope || 'not-exposed'}`) + if (!agent?.id || !agent?.name) return + const data = fullDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name }) + const create = await request(context, 'POST', BASE, { expectedVersion: 0, data }, + { 'Idempotency-Key': `${context.marker}-ocean-draft-create`.slice(0, 128) }) + const created = payload(create); const bookingId = id(created?.oceanBooking?.id) + context.apiSuccess(MODULE, '创建订舱 DRAFT(不生成真实柜号/货柜分配)', create, '', OPTIONS) + context.assert(MODULE, '订舱创建返回 DRAFT 且只持久化柜型需求', create, + Boolean(bookingId) && created?.oceanBooking?.status === 'DRAFT' + && Array.isArray(created?.containerRequirements) && created.containerRequirements.length === 2 + && (!created.containers || created.containers.length === 0), + `bookingId=${bookingId || 'missing'}, status=${created?.oceanBooking?.status || 'missing'}, requirements=${created?.containerRequirements?.length ?? 'n/a'}, containers=${created?.containers?.length ?? 0}`, OPTIONS) if (!bookingId) return - context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker, - containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' } - const replay = await createBooking(context, suffix, key, master, bookingAgentSnapshot) - context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS) - const existingContainerNo = detail?.containers?.[0]?.containerNo - const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{ - containerNo: existingContainerNo, containerType: detail?.containers?.[0]?.containerType, - containerKind: detail?.containers?.[0]?.containerKind, capacityCbm: detail?.containers?.[0]?.capacityCbm, - maxWeightKg: detail?.containers?.[0]?.maxWeightKg, - }] }) - const globalDuplicate = await request(context, 'POST', BASE, - { expectedVersion: 0, data: globalDuplicateDraft }, - { 'Idempotency-Key': `${context.marker}-ocean-global-duplicate`.slice(0, 128) }) - step(context, '数据库全局重复真实柜号原子拒绝', globalDuplicate, - Boolean(existingContainerNo) && failure(globalDuplicate), - `containerNo=${existingContainerNo || 'missing'}, HTTP=${globalDuplicate.http}, code=${codeOf(globalDuplicate)}`) - const path = `${BASE}/${bookingId}` - let version = Number(detail.oceanBooking.version) - const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d } - const changed = await request(context, 'POST', `${path}/change-request`, { - expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, - estimatedSailingAt: detail.oceanBooking.estimatedSailingAt, - estimatedArrivalAt: detail.oceanBooking.estimatedArrivalAt, - reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-change` }) - context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) - const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') - const releaseSnapshotResponse = await request(context, 'GET', path) - const releaseSnapshot = data(releaseSnapshotResponse) - const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] - const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, releaseSnapshot?.oceanBooking?.[field]])) - const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') - const frozenTransportMatches = frozenTransportValid - && transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field])) - context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshotResponse, frozenTransportMatches, - `bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) - if (!frozenTransportMatches) return - const allocatedContainerBookingId = '' - if (['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)) { - context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, - id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), - `bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS) - const realContainer = changeConfirmed?.containers?.find?.((item) => item?.containerType === '40HC') - context.assert(MODULE, '独立订舱存在完整匹配柜型的真实柜身份', changed, - Boolean(realContainer?.containerBookingId && realContainer?.containerNo && realContainer?.requirementId && realContainer?.allocationId), - `containerBookingId=${id(realContainer?.containerBookingId) || 'missing'}`, OPTIONS) - if (!realContainer?.containerBookingId || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return - context.assert(MODULE, '独立订舱真实柜不占用本轮已装货柜次身份', changed, - !changeConfirmed?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow?.containerBookingId)) - && Number.isInteger(version), - `containers=${changeConfirmed?.containers?.length ?? 'n/a'}, version=${version}`, OPTIONS) - } - const released = await request(context, 'POST', `${path}/release`, { - expectedVersion: version, ...frozenTransport, reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-ocean-release` }) - const releasedDetail = data(released) - context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS) - version = Number(releasedDetail?.oceanBooking?.version) - const final = await request(context, 'GET', path); const fd = data(final) - context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS) - const bookingFieldGaps = oceanBookingFieldGaps(fd?.oceanBooking) - context.assert(MODULE, '订舱列表所需客户、船公司、代理、航次与关键时间字段来源完整', final, - bookingFieldGaps.length === 0, `missing=${bookingFieldGaps.join(',') || 'none'}`, OPTIONS) - context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final, - fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 4 && fd.histories.every((h) => h.operatorName) - && fd?.containers?.length === 2, - `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, containers=${fd?.containers?.length ?? 'n/a'}`, OPTIONS) - let downstreamBooking = { - id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker, - containerBookingId: allocatedContainerBookingId || null, - loadingPlanId: null, - finalStatus: fd?.oceanBooking?.status || null, - } - const releasedLoadingBooking = await releaseLoadingBooking(context, containerFlow, suffix) - if (releasedLoadingBooking) downstreamBooking = releasedLoadingBooking - context.createdResources.oceanBooking = downstreamBooking - const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) - step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`) + const get = await request(context, 'GET', `${BASE}/${bookingId}`); const detail = payload(get) + step(context, '回读 DRAFT 真实持久化快照', get, detail?.oceanBooking?.status === 'DRAFT', `bookingId=${bookingId}`) + const draftVersion = Number(detail?.oceanBooking?.version) + const partial = { ...data, vesselName: null, voyageNo: null, estimatedSailingAt: null, estimatedArrivalAt: null } + const edit = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: draftVersion, data: partial }, + { 'Idempotency-Key': `${context.marker}-ocean-draft-edit`.slice(0, 128) }) + step(context, 'DRAFT 可部分保存/编辑', edit, !failed(edit), `bookingId=${bookingId}, HTTP=${edit.http}, code=${codeOf(edit)}`) + const refreshed = payload(edit) || detail; const releaseVersion = Number(refreshed?.oceanBooking?.version ?? draftVersion) + const release = await request(context, 'POST', `${BASE}/${bookingId}/release`, { expectedVersion: releaseVersion, reason: context.marker }, + { 'Idempotency-Key': `${context.marker}-ocean-release-incomplete`.slice(0, 128) }) + step(context, '不完整 DRAFT 放舱明确拒绝且无状态漂移', release, failed(release), + `bookingId=${bookingId}, HTTP=${release.http}, code=${codeOf(release)}`) + const complete = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: releaseVersion, data }, + { 'Idempotency-Key': `${context.marker}-ocean-draft-complete`.slice(0, 128) }) + const completedDraft = payload(complete); const completeVersion = Number(completedDraft?.oceanBooking?.version ?? releaseVersion) + step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`) + if (failed(complete)) return + const frozen = transportSnapshot(completedDraft?.oceanBooking || data) + const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`, + { expectedVersion: completeVersion, reason: context.marker }, + { 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) }) + const released = payload(finalRelease) + step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease, + !failed(finalRelease) && released?.oceanBooking?.status === 'RELEASED' && hasTransport(released.oceanBooking) + && Object.entries(frozen).every(([key, value]) => String(released.oceanBooking[key]) === String(value)), + `bookingId=${bookingId}, status=${released?.oceanBooking?.status || 'missing'}`) + if (failed(finalRelease)) return + const releasedVersion = Number(released?.oceanBooking?.version) + const immutable = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: releasedVersion, data }, + { 'Idempotency-Key': `${context.marker}-ocean-released-edit`.slice(0, 128) }) + step(context, 'RELEASED 编辑拒绝且零写入', immutable, failed(immutable), `HTTP=${immutable.http}, code=${codeOf(immutable)}`) + const relist = await request(context, 'GET', `${BASE}/${bookingId}`); const relistData = payload(relist) + step(context, 'RELEASED 回查运输快照未漂移且没有预生成货柜', relist, + relistData?.oceanBooking?.status === 'RELEASED' && (!relistData?.containers || relistData.containers.length === 0) + && Object.entries(frozen).every(([key, value]) => String(relistData.oceanBooking[key]) === String(value)), `bookingId=${bookingId}`) + const gaps = oceanBookingFieldGaps(relistData?.oceanBooking) + step(context, '订舱正式列表字段来源完整', relist, gaps.length === 0, `missing=${gaps.join(',') || 'none'}`) + context.createdResources.oceanBooking = { id: bookingId, marker: context.marker, + customerId: id(relistData?.oceanBooking?.customerId), bookingNo: id(relistData?.oceanBooking?.bookingNo), + finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null, + requirements: (relistData?.containerRequirements || []).map((value) => ({ requirementId: id(value.id), ...value })) } }