This commit is contained in:
2026-08-18 16:03:42 +08:00
parent 12e9f3746e
commit 82ee80226e
15 changed files with 196 additions and 319 deletions
@@ -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<Order> 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<Item> 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) {}
}
@@ -22,7 +22,7 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> 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()));} public ApiResponse<Plan> 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')") @PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> 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()));} public ApiResponse<Plan> 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<Plan> 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()));} public ApiResponse<Plan> 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')") @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> 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()));} public ApiResponse<Plan> 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()));}
@@ -192,7 +192,8 @@ public final class LoadingListDtos {
String customerRemark, String customerRemark,
String warehouseRemark, String warehouseRemark,
int loadSequence, int loadSequence,
LocalDateTime frozenAt LocalDateTime frozenAt,
Integer itemVersion
) { ) {
} }
@@ -25,6 +25,7 @@ public interface LoadingCompletedEventMapper {
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, 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.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.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 FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id 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 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 Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
private String customerRemark; private String internalRemark; private String warehouseRemark;
} }
} }
@@ -178,7 +178,7 @@ public interface LoadingListQueryMapper {
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg, gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no, product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark, 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 FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id 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' 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 String warehouseRemark;
private Integer loadSequence; private Integer loadSequence;
private LocalDateTime frozenAt; private LocalDateTime frozenAt;
private Integer itemVersion;
} }
@Getter @Getter
@@ -10,7 +10,6 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> { public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@@ -53,42 +52,4 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName); @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);
} }
@@ -47,15 +47,6 @@ public class OceanBookingRepository {
histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id)); histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id));
return bookings.deleteDraft(id, version); 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<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
@@ -8,7 +8,7 @@ import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes; 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.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper;
@@ -33,7 +33,7 @@ import java.util.Map;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */ /** 在装柜完成事务内按客户写跨区业务 Outbox。 */
@Service @Service
public class LoadingCompletedOutboxService { public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 1L; private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private InboundOutboxMapper outboxMapper; @Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -50,32 +50,33 @@ public class LoadingCompletedOutboxService {
Row first = rows.get(0); Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV1.Order> orders = byInbound.values().stream() List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(this::order).toList(); .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(), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders); string(first.getWarehouseId()), first.getWarehouseName(), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload); JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
String traceId = EventIdentifiers.newUuid(); 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", Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
traceId, eventKey, null, payloadNode, hash).requireValid(); traceId, eventKey, null, payloadNode, hash).requireValid();
} }
private FirstLegLoadingPlanCompletedV1.Order order(List<Row> values) { private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0); Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV1.Item> items = sorted.stream().map(row -> List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(),
row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(),
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(),
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), 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(); .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.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
@@ -405,6 +405,18 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}}
responses: responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} '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: /api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:
post: post:
summary: 推送智能装柜计划到预装清单 summary: 推送智能装柜计划到预装清单
@@ -1531,6 +1543,14 @@ components:
expectedPlanVersion: {type: integer, minimum: 0} expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0} expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128} 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: SmartLoadingPlan:
type: object type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
@@ -1562,6 +1582,9 @@ components:
orderNo: {type: string, nullable: true} orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true} inboundCode: {type: string, nullable: true}
customerName: {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} packageCount: {type: integer, minimum: 1}
version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值} version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值}
pickedPackageCount: {type: integer, minimum: 0} pickedPackageCount: {type: integer, minimum: 0}
@@ -23,6 +23,7 @@ class PlatformSmartLoadingControllerContractTest {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); 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("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("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("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); authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
} }
@@ -42,6 +43,8 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingAssignRequest:") .contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:") .contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:") .contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("canAssign: {type: boolean}") .contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}") .contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}") .contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -46,7 +46,8 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().get("orders")).hasSize(2); assertThat(first.payload().get("orders")).hasSize(2);
assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100");
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); 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 { @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; 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.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; 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( when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
4, 100, new BigDecimal("10"), "货物备注"))); 4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注")));
doAnswer(invocation -> { doAnswer(invocation -> {
Object value = invocation.getArgument(0); Object value = invocation.getArgument(0);
if (value instanceof LoadingPlanItemEntity item) item.setId(88L); if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
@@ -255,7 +256,9 @@ class SmartLoadingServiceTest {
assertThat(saved.getUnitsPerCarton()).isEqualTo(4); assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20); assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5"); assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
assertThat(saved.getInternalRemark()).isEqualTo("货物备注"); assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(historyCaptor.capture()); verify(repository).insert(historyCaptor.capture());
@@ -378,6 +381,29 @@ class SmartLoadingServiceTest {
verify(repository).touch(1, 2, 7, "u"); 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<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@Test @Test
void preservesDownstreamCode() { void preservesDownstreamCode() {
stubAssignment(); stubAssignment();
@@ -3,6 +3,7 @@ package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -44,6 +45,10 @@ class InternalOrderCargoControllerTest {
cargo.setNetWeightKg(new java.math.BigDecimal("18.500")); cargo.setNetWeightKg(new java.math.BigDecimal("18.500"));
cargo.setCargoRemark("内部货物备注"); cargo.setCargoRemark("内部货物备注");
when(orders.findCargoInheritedByOrder(11L, 21L, 31L)).thenReturn(cargo); 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); var response = controller.detail("internal-token", 11L, 21L, 31L);
@@ -56,6 +61,8 @@ class InternalOrderCargoControllerTest {
assertThat(response.data().totalQuantity()).isEqualTo(200); assertThat(response.data().totalQuantity()).isEqualTo(200);
assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500"); assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500");
assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注"); assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注");
assertThat(response.data().customerRemark()).isEqualTo("客户订单备注");
assertThat(response.data().latestInternalNote()).isEqualTo("最新内部备注");
verify(orders).findCargoInheritedByOrder(11L, 21L, 31L); verify(orders).findCargoInheritedByOrder(11L, 21L, 31L);
} }
@@ -99,6 +106,9 @@ class InternalOrderCargoControllerTest {
OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30");
cargo.setOrderId(21L); cargo.setOrderId(21L);
when(orders.findCargoInheritedByOrder(11L, 201L, 31L)).thenReturn(cargo); 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); var response = controller.detail("internal-token", 11L, 201L, 31L);
@@ -55,9 +55,8 @@ export async function resolveOceanBookingMaster(context, module) {
export function oceanBookingDraft(context, suffix, master, labels = {}) { export function oceanBookingDraft(context, suffix, master, labels = {}) {
const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master
const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING' const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING'
const containers = labels.containers || [{ const containerRequirements = labels.containerRequirements || [{
containerNo: `TST-${normalizedSuffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000,
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
}] }]
return { return {
...writableMaster, ...writableMaster,
@@ -75,7 +74,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containers, containerRequirements,
} }
} }
+84 -251
View File
@@ -1,269 +1,102 @@
const MODULE = 'ocean-bookings' const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true } const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings' 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' import { oceanBookingFieldGaps } from './fulfillment-list-field-contract.mjs'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function missing(r) { return r?.http === 404 || r?.http === 405 || r?.http === 503 } function payload(response) { return codeOf(response) === '200' ? response.body.data : null }
function failure(r) { return [400, 401, 403, 404, 409, 503].includes(r?.http) || (r?.http === 200 && codeOf(r) !== '200') } function failed(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') }
async function request(context, method, path, body, headers = {}) { async function request(context, method, path, body, headers = {}) {
try { 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 response = await fetch(`${context.options.gateway}${path}`, {
const raw = await response.text(); let payload = null method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
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') } 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 } } } 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 } function step(context, name, response, pass, evidence = '') { context.addStep(MODULE, name, response, pass, evidence, OPTIONS) }
async function createBooking(context, suffix, key, master, bookingAgent) { function fullDraft(context, suffix, master, labels) {
const containers = [ return oceanBookingDraft(context, suffix, master, {
{ containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }, ...labels,
{ containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 }, containerRequirements: labels.containerRequirements || [
] { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 },
return request(context, 'POST', BASE, { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 },
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { ],
containers, })
bookingAgentId: bookingAgent.supplierId,
bookingAgentName: bookingAgent.name,
}) },
{ 'Idempotency-Key': key })
} }
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } function transportSnapshot(value) {
return ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
async function createConfirmedUnassignedBooking(context, suffix, master) { .reduce((out, key) => { out[key] = value?.[key]; return out }, {})
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 hasTransport(value) { return Object.values(transportSnapshot(value)).every((v) => v != null && String(v).trim() !== '') }
export async function run(context) { 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) const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return if (!master) return
const bookingAgentResponse = await request(context, 'GET', const suffix = context.marker.replace(/\D/g, '').slice(-10) || 'BOOKING'
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') const agentResponse = 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 agentPage = payload(agentResponse)
const bookingAgent = bookingAgentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') const agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
context.assert(MODULE, '订舱供应商不区分国内或国外', bookingAgentResponse, step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name),
Boolean(bookingAgent?.id && bookingAgent?.name) && !Object.hasOwn(bookingAgent || {}, 'scope'), `supplierId=${id(agent?.id) || 'missing'}, scope=${agent?.scope || 'not-exposed'}`)
`supplierId=${id(bookingAgent?.id) || 'missing'}, scopeExposed=${Object.hasOwn(bookingAgent || {}, 'scope')}`, OPTIONS) if (!agent?.id || !agent?.name) return
if (!bookingAgent?.id || !bookingAgent?.name || Object.hasOwn(bookingAgent, 'scope')) return const data = fullDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name })
const bookingAgentSnapshot = { supplierId: id(bookingAgent.id), name: bookingAgent.name } const create = await request(context, 'POST', BASE, { expectedVersion: 0, data },
const list = await request(context, 'GET', `${BASE}?page=1&size=5`) { 'Idempotency-Key': `${context.marker}-ocean-draft-create`.slice(0, 128) })
if (missing(list) || list.http === 0) { const created = payload(create); const bookingId = id(created?.oceanBooking?.id)
step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`) context.apiSuccess(MODULE, '创建订舱 DRAFT(不生成真实柜号/货柜分配)', create, '', OPTIONS)
return context.assert(MODULE, '订舱创建返回 DRAFT 且只持久化柜型需求', create,
} Boolean(bookingId) && created?.oceanBooking?.status === 'DRAFT'
const page = context.apiSuccess(MODULE, '订舱列表分页查询', list, '', OPTIONS) && Array.isArray(created?.containerRequirements) && created.containerRequirements.length === 2
context.assert(MODULE, '订舱列表分页结构正确', list, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS) && (!created.containers || created.containers.length === 0),
const duplicateNo = `TST-${suffix}-DUP`.slice(0, 64) `bookingId=${bookingId || 'missing'}, status=${created?.oceanBooking?.status || 'missing'}, requirements=${created?.containerRequirements?.length ?? 'n/a'}, containers=${created?.containers?.length ?? 0}`, OPTIONS)
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)
if (!bookingId) return if (!bookingId) return
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker, const get = await request(context, 'GET', `${BASE}/${bookingId}`); const detail = payload(get)
containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' } step(context, '回读 DRAFT 真实持久化快照', get, detail?.oceanBooking?.status === 'DRAFT', `bookingId=${bookingId}`)
const replay = await createBooking(context, suffix, key, master, bookingAgentSnapshot) const draftVersion = Number(detail?.oceanBooking?.version)
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS) const partial = { ...data, vesselName: null, voyageNo: null, estimatedSailingAt: null, estimatedArrivalAt: null }
const existingContainerNo = detail?.containers?.[0]?.containerNo const edit = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: draftVersion, data: partial },
const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{ { 'Idempotency-Key': `${context.marker}-ocean-draft-edit`.slice(0, 128) })
containerNo: existingContainerNo, containerType: detail?.containers?.[0]?.containerType, step(context, 'DRAFT 可部分保存/编辑', edit, !failed(edit), `bookingId=${bookingId}, HTTP=${edit.http}, code=${codeOf(edit)}`)
containerKind: detail?.containers?.[0]?.containerKind, capacityCbm: detail?.containers?.[0]?.capacityCbm, const refreshed = payload(edit) || detail; const releaseVersion = Number(refreshed?.oceanBooking?.version ?? draftVersion)
maxWeightKg: detail?.containers?.[0]?.maxWeightKg, const release = await request(context, 'POST', `${BASE}/${bookingId}/release`, { expectedVersion: releaseVersion, reason: context.marker },
}] }) { 'Idempotency-Key': `${context.marker}-ocean-release-incomplete`.slice(0, 128) })
const globalDuplicate = await request(context, 'POST', BASE, step(context, '不完整 DRAFT 放舱明确拒绝且无状态漂移', release, failed(release),
{ expectedVersion: 0, data: globalDuplicateDraft }, `bookingId=${bookingId}, HTTP=${release.http}, code=${codeOf(release)}`)
{ 'Idempotency-Key': `${context.marker}-ocean-global-duplicate`.slice(0, 128) }) const complete = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: releaseVersion, data },
step(context, '数据库全局重复真实柜号原子拒绝', globalDuplicate, { 'Idempotency-Key': `${context.marker}-ocean-draft-complete`.slice(0, 128) })
Boolean(existingContainerNo) && failure(globalDuplicate), const completedDraft = payload(complete); const completeVersion = Number(completedDraft?.oceanBooking?.version ?? releaseVersion)
`containerNo=${existingContainerNo || 'missing'}, HTTP=${globalDuplicate.http}, code=${codeOf(globalDuplicate)}`) step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`)
const path = `${BASE}/${bookingId}` if (failed(complete)) return
let version = Number(detail.oceanBooking.version) const frozen = transportSnapshot(completedDraft?.oceanBooking || data)
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 finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`,
const changed = await request(context, 'POST', `${path}/change-request`, { { expectedVersion: completeVersion, reason: context.marker },
expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, { 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) })
estimatedSailingAt: detail.oceanBooking.estimatedSailingAt, const released = payload(finalRelease)
estimatedArrivalAt: detail.oceanBooking.estimatedArrivalAt, step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease,
reason: context.marker, !failed(finalRelease) && released?.oceanBooking?.status === 'RELEASED' && hasTransport(released.oceanBooking)
}, { 'Idempotency-Key': `${context.marker}-change` }) && Object.entries(frozen).every(([key, value]) => String(released.oceanBooking[key]) === String(value)),
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) `bookingId=${bookingId}, status=${released?.oceanBooking?.status || 'missing'}`)
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') if (failed(finalRelease)) return
const releaseSnapshotResponse = await request(context, 'GET', path) const releasedVersion = Number(released?.oceanBooking?.version)
const releaseSnapshot = data(releaseSnapshotResponse) const immutable = await request(context, 'PUT', `${BASE}/${bookingId}`, { expectedVersion: releasedVersion, data },
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] { 'Idempotency-Key': `${context.marker}-ocean-released-edit`.slice(0, 128) })
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, releaseSnapshot?.oceanBooking?.[field]])) step(context, 'RELEASED 编辑拒绝且零写入', immutable, failed(immutable), `HTTP=${immutable.http}, code=${codeOf(immutable)}`)
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') const relist = await request(context, 'GET', `${BASE}/${bookingId}`); const relistData = payload(relist)
const frozenTransportMatches = frozenTransportValid step(context, 'RELEASED 回查运输快照未漂移且没有预生成货柜', relist,
&& transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field])) relistData?.oceanBooking?.status === 'RELEASED' && (!relistData?.containers || relistData.containers.length === 0)
context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshotResponse, frozenTransportMatches, && Object.entries(frozen).every(([key, value]) => String(relistData.oceanBooking[key]) === String(value)), `bookingId=${bookingId}`)
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) const gaps = oceanBookingFieldGaps(relistData?.oceanBooking)
if (!frozenTransportMatches) return step(context, '订舱正式列表字段来源完整', relist, gaps.length === 0, `missing=${gaps.join(',') || 'none'}`)
const allocatedContainerBookingId = '' context.createdResources.oceanBooking = { id: bookingId, marker: context.marker,
if (['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus)) { customerId: id(relistData?.oceanBooking?.customerId), bookingNo: id(relistData?.oceanBooking?.bookingNo),
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), requirements: (relistData?.containerRequirements || []).map((value) => ({ requirementId: id(value.id), ...value })) }
`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)}`)
} }