This commit is contained in:
2026-08-18 16:39:39 +08:00
parent 7fe5b4e31f
commit a9061eca54
43 changed files with 821 additions and 1217 deletions
@@ -103,9 +103,7 @@ public final class ContainerDtos {
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号")
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {
@Deprecated public OceanBookingLinkRequest(Long legacyRealContainerBookingId, Integer expectedVersion) { this(null, null, null, expectedVersion); }
}
@NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@@ -107,50 +107,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE merged_into_container_booking_id=#{targetId} AND record_status='MERGED' FOR UPDATE")
ContainerBookingEntity selectMergedSourceForUpdate(@Param("targetId") long targetId);
@Update("""
UPDATE fl_container_booking SET record_status='MERGED',
merged_into_container_booking_id=#{targetId}, container_no=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='ACTIVE'
AND merged_into_container_booking_id IS NULL AND container_no IS NOT NULL
""")
int consumeRealSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking target
JOIN fl_container_booking source ON source.id=#{sourceId}
SET target.container_no=#{containerNo}, target.ocean_booking_id=source.ocean_booking_id,
target.ocean_booking_requirement_id=source.ocean_booking_requirement_id,
target.customer_id=source.customer_id, target.customer_name=source.customer_name,
target.bill_of_lading_no=source.bill_of_lading_no,
target.vessel_name=source.vessel_name, target.voyage_no=source.voyage_no,
target.shipping_company_id=source.shipping_company_id,
target.shipping_company_name=source.shipping_company_name,
target.departure_port_code=source.departure_port_code,
target.departure_port_name=source.departure_port_name,
target.destination_port_code=source.destination_port_code,
target.destination_port_name=source.destination_port_name,
target.estimated_sailing_at=source.estimated_sailing_at,
target.estimated_port_arrival_at=source.estimated_port_arrival_at,
target.updated_by=#{operatorId}, target.updated_by_name=#{operatorName},
target.updated_at=CURRENT_TIMESTAMP(3), target.version=target.version+1
WHERE target.id=#{targetId} AND target.version=#{expectedVersion}
AND target.source_type='SIMULATION' AND target.record_status='ACTIVE'
AND target.ocean_booking_id IS NULL AND source.record_status='MERGED'
AND source.merged_into_container_booking_id=target.id
""")
int mergeRealSource(@Param("targetId") long targetId, @Param("sourceId") long sourceId,
@Param("containerNo") String containerNo,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
@@ -164,23 +120,10 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
""")
int clearMergedLink(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
int unlinkReleasedBooking(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET record_status='ACTIVE',
merged_into_container_booking_id=NULL, container_no=#{containerNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='MERGED'
AND merged_into_container_booking_id=#{targetId} AND container_no IS NULL
""")
int restoreMergedSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("containerNo") String containerNo,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
@@ -37,10 +37,6 @@ public final class OceanBookingDtos {
@NotNull @Min(1) @Max(100) Integer quantity,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Deprecated
public record ContainerRequest(String containerNo, String containerType, String containerKind,
BigDecimal capacityCbm, BigDecimal maxWeightKg) {}
public record DraftData(@Positive Long customerId,
@Size(max = 128) String customerName,
@Size(max = 64) String channelCode,
@@ -63,20 +59,7 @@ public final class OceanBookingDtos {
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {
public DraftData(Long customerId, String customerName, String channelCode, Long warehouseId, String billOfLadingNo,
Long shippingCompanyId, String shippingCompanyName, Long bookingAgentId, String bookingAgentName,
String vesselName, String voyageNo, String departurePortCode, String departurePortName,
String destinationPortCode, String destinationPortName, String placeOfDelivery, String serviceTerm,
String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String remark,
List<PartyRequest> parties, java.util.Collection<?> legacyContainers) {
this(customerId, customerName, channelCode, warehouseId, billOfLadingNo, shippingCompanyId, shippingCompanyName,
bookingAgentId, bookingAgentName, vesselName, voyageNo, departurePortCode, departurePortName, destinationPortCode,
destinationPortName, placeOfDelivery, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, remark,
parties, legacyContainers == null ? List.of() : legacyContainers.stream().map(v -> v instanceof ContainerRequirementRequest x ? x : v instanceof ContainerRequest x ? new ContainerRequirementRequest(x.containerType(), x.containerKind(), 1, x.capacityCbm(), x.maxWeightKg()) : null).filter(java.util.Objects::nonNull).toList());
}
public List<ContainerRequest> containers() { return containerRequirements.stream().map(v -> new ContainerRequest(null, v.containerType(), v.containerKind(), v.capacityCbm(), v.maxWeightKg())).toList(); }
}
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
@@ -85,14 +68,7 @@ public final class OceanBookingDtos {
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {
public ReleaseRequest(int expectedVersion, String bill, String vessel, String voyage, LocalDateTime etd, LocalDateTime eta, String reason) {
this(expectedVersion, reason);
}
}
@Deprecated
public record ChangeRequest(Integer expectedVersion, String vesselName, String voyageNo,
LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String reason) {}
@Size(max = 1000) String reason) {}
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
Long requirementId, Long allocationId, String containerType,
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
@@ -40,12 +40,6 @@ public class OceanBookingEntity {
private String freightTerm;
private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt;
private String pendingVesselName;
private String pendingVoyageNo;
private LocalDateTime pendingEstimatedSailingAt;
private LocalDateTime pendingEstimatedArrivalAt;
private String changeReason;
private String cancellationReason;
private String remark;
private Long createdBy;
private String createdByName;
@@ -43,13 +43,12 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},
cancellation_reason=#{cancellationReason},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status=#{fromStatus} AND version=#{expectedVersion}
""")
int transition(@Param("oceanBookingId") long oceanBookingId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("cancellationReason") String cancellationReason,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -19,7 +19,7 @@ public interface OceanBookingOperationMapper extends BaseMapper<OceanBookingOper
@Update("UPDATE fl_ocean_booking_operation SET ocean_booking_id=#{oceanBookingId},status='SUCCESS',"
+ "result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("oceanBookingId") long oceanBookingId, @Param("operatorId") long operatorId,
int complete(@Param("oceanBookingId") Long oceanBookingId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key,
@Param("resultJson") String resultJson);
}
@@ -31,20 +31,14 @@ public class OceanBookingRepository {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
@Deprecated public int release(long id, Object ignored, long operatorId, String operatorName) { return 0; }
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, String cancellationReason,
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, cancellationReason, version, operatorId, operatorName);
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, version, operatorId, operatorName);
}
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateDraft(value.getId(), value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) {
allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id));
requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id));
parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id));
histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id));
return bookings.deleteDraft(id, version);
}
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
@@ -86,5 +80,5 @@ public class OceanBookingRepository {
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper<OceanBookingOperationEntity>().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); }
public int completeOperation(long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
public int completeOperation(Long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
}
@@ -112,18 +112,15 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
@Deprecated public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) { throw conflict("订舱仅支持DRAFT和RELEASED,不能取消"); }
@Deprecated public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) { throw conflict("RELEASED订舱不可变更"); }
@Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
Detail replay = claimOrReplay(null, operatorId, action, key, hash(action, id, request));
if (replay != null) return;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
if (repository.deleteDraft(id, request.expectedVersion()) != 1) throw concurrent();
complete(id, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
@@ -134,7 +131,7 @@ public class OceanBookingService {
requireStatus(before, OceanBookingStatus.DRAFT);
validateRelease(before, repository.parties(id), repository.requirements(id));
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
@@ -153,7 +150,7 @@ public class OceanBookingService {
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取订舱幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String action, String key, Detail detail) {
private Detail complete(Long id, long operatorId, String action, String key, Detail detail) {
try {
if (repository.completeOperation(id, operatorId, action, key, objectMapper.writeValueAsString(detail)) != 1) {
throw conflict("订舱幂等请求完成状态冲突");
@@ -259,50 +256,6 @@ public class OceanBookingService {
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
/* real container rows are created only by direct container linking after release. */
/*
for (int index = 0; index < requests.size(); index++) {
ContainerRequirementRequest request = requests.get(index);
OceanBookingContainerRequirementEntity requirement = requirements.get(index);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + container.getId());
container.setContainerNo(request.containerNo().trim());
container.setSourceType("OCEAN_BOOKING"); container.setRecordStatus("ACTIVE");
container.setChannelCode(booking.getChannelCode()); container.setChannelName(booking.getChannelName());
container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setBillOfLadingNo(booking.getBillOfLadingNo());
container.setVesselName(booking.getVesselName()); container.setVoyageNo(booking.getVoyageNo());
container.setContainerType(requirement.getContainerType());
container.setShippingCompanyId(booking.getShippingCompanyId());
container.setShippingCompanyName(booking.getShippingCompanyName());
container.setDeparturePortCode(booking.getDeparturePortCode());
container.setDeparturePortName(booking.getDeparturePortName());
container.setDestinationPortCode(booking.getDestinationPortCode());
container.setDestinationPortName(booking.getDestinationPortName());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
container.setEstimatedSailingAt(booking.getEstimatedSailingAt());
container.setEstimatedPortArrivalAt(booking.getEstimatedArrivalAt());
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
try { repository.insertContainer(container); }
catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在:" + request.containerNo().trim());
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
allocation.setRequirementId(requirement.getId());
allocation.setContainerBookingId(container.getId()); allocation.setAllocatedBy(operatorId);
allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now());
repository.insert(allocation);
}
}
*/
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
@@ -18,9 +18,6 @@ public class ContainerRepository {
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public ContainerBookingEntity mergedSourceForUpdate(long targetId) {
return mapper.selectMergedSourceForUpdate(targetId);
}
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -33,19 +30,8 @@ public class ContainerRepository {
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int consumeRealSource(long sourceId, long targetId, long operatorId, String operatorName) {
return mapper.consumeRealSource(sourceId, targetId, operatorId, operatorName);
}
public int mergeRealSource(long targetId, long sourceId, String containerNo, int version,
long operatorId, String operatorName) {
return mapper.mergeRealSource(targetId, sourceId, containerNo, version, operatorId, operatorName);
}
public int clearMergedLink(long targetId, int version, long operatorId, String operatorName) {
return mapper.clearMergedLink(targetId, version, operatorId, operatorName);
}
public int restoreMergedSource(long sourceId, long targetId, String containerNo,
long operatorId, String operatorName) {
return mapper.restoreMergedSource(sourceId, targetId, containerNo, operatorId, operatorName);
public int unlinkReleasedBooking(long targetId, int version, long operatorId, String operatorName) {
return mapper.unlinkReleasedBooking(targetId, version, operatorId, operatorName);
}
public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo,
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking,
@@ -107,14 +107,10 @@ public class ContainerService {
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
ContainerBookingEntity source = repository.mergedSourceForUpdate(id);
if (source == null) throw conflict("真实订舱柜合并来源不存在,不能解除关联");
String containerNo = container.getContainerNo();
if (repository.clearMergedLink(id, request.expectedVersion(), operatorId, operatorName) != 1
|| repository.restoreMergedSource(source.getId(), id, containerNo, operatorId, operatorName) != 1
|| oceanBookings.reassignAllocation(id, source.getId()) != 1) {
if (repository.unlinkReleasedBooking(id, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
oceanBookings.deleteAllocation(id);
return required(id);
}
@@ -218,6 +218,7 @@ public class SmartLoadingService {
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String remark = trim(request.warehouseRemark());
if (!StringUtils.hasText(remark)) remark = null;
String requestHash = sha256("WAREHOUSE_REMARK|" + planId + '|' + itemId + '|'
+ Objects.toString(remark, "") + '|' + request.expectedPlanVersion() + '|'
+ request.expectedItemVersion());
@@ -27,12 +27,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号',
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
@@ -49,10 +43,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
(pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL
OR pending_estimated_arrival_at >= pending_estimated_sailing_at)
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional.
@@ -60,10 +51,7 @@ ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
-- Reconcile databases created before the direct-CONFIRMED contract. This is
-- intentionally fail-closed: no legacy row is promoted and no transport term
-- is guessed. Referenced legacy aggregates must be archived/deleted by the
-- owning module first, then this block can be rerun safely.
-- Recreate triggers and the current two-state constraint on repeatable initialization.
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
@@ -112,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY'))
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
@@ -127,7 +115,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0)
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
@@ -142,7 +130,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id)
REFERENCES fl_ocean_booking_container_requirement(id),
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
@@ -162,7 +150,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
@@ -179,7 +167,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希';
@@ -188,7 +176,7 @@ DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -256,8 +244,8 @@ BEGIN
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete'
WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate'
END)
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
@@ -278,8 +266,8 @@ VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,0),
(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
@@ -251,14 +251,14 @@ paths:
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜原地合并为指定真实订舱柜
description: 需要 platform:firstleg:container:edit;请求指定未消费且尚无装柜计划/PDA/证据/单证的 realContainerBookingId。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,将真实柜 containerNo、订舱、运输和范围快照原子复制到模拟柜并把 allocation 改指模拟柜;原真实柜置 MERGED 且不再出现在正常货柜列表。存在跨仓、跨渠道、柜型规格不一致、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 合并后的同一模拟柜 containerBookingId/cabinetNo;列表仅保留这一条 ACTIVE 操作身份}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
delete:
summary: 解除内部模拟柜的真实订舱关联
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;原子清除当前柜订舱/运输快照和 allocation,不恢复或创建其他柜记录。任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}}
responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}}
@@ -408,7 +408,11 @@ paths:
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
patch:
summary: 修改装柜明细仓库备注
description: EDITING/LOADING 可修改或清空 warehouseRemark;COMPLETED、计划/明细版本冲突返回 A0409,写入历史且与 PDA final complete 使用同一并发门禁。
description: >-
需要 platform:firstleg:preload:edit 权限。EDITING/LOADING 可修改或清空 warehouseRemark;
COMPLETED、计划/明细版本冲突返回 A0409 且事务零写入,写入历史且与 PDA final complete 使用同一并发门禁。
成功响应中的 version 是递增后的 planVersion,items[].version 是递增后的 itemVersion;调用方同时使用
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有 warehouseRemark。
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]*$'}}
@@ -29,7 +29,17 @@ paths:
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/channels:
get: {summary: List ACTIVE channels for an origin warehouse, responses: {'200': {description: Channel options}}}
get:
summary: List ACTIVE channels for an origin warehouse
description: Requires platform:firstleg:ocean-booking:list. Channel names are resolved from the current ACTIVE price book and frozen by the server on save.
parameters:
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: Channel options
content:
application/json:
schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -48,8 +58,15 @@ components:
billOfLadingNo: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
bookingAgentId: {type: integer, format: int64, nullable: true}
bookingAgentName: {type: string, nullable: true}
bookingAgentId:
type: integer
format: int64
nullable: true
description: Warehouse supplier ID; release validates ACTIVE category BOOKING; booking suppliers do not use domestic/international scope.
bookingAgentName:
type: string
nullable: true
description: Read-only consistency hint; the persisted snapshot always comes from Warehouse.
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
departurePortCode: {type: string, nullable: true}
@@ -68,4 +85,13 @@ components:
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ChannelOption:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
properties:
priceBookVersionId: {type: integer, format: int64}
channelCode: {type: string}
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}
@@ -60,10 +60,12 @@ class ContainerControllerContractTest {
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("尚无装柜计划/PDA/证据/单证的 realContainerBookingId")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("realContainerBookingId")
.doesNotContain("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
@@ -75,6 +77,7 @@ class ContainerControllerContractTest {
.doesNotContain("tenantId")
.doesNotContain("accountId");
assertThat(openapi)
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
@@ -45,6 +45,11 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("需要 platform:firstleg:preload:edit 权限")
.contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000")
.contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -23,10 +23,9 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("release", "platform:firstleg:ocean-booking:release"),
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"));
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
Map.entry("release", "platform:firstleg:ocean-booking:release"));
permissions.forEach((name, permission) -> {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
@@ -51,28 +50,11 @@ class PlatformOceanBookingControllerContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]")
.contains("containerNo: {type: string, maxLength: 64")
.contains("containers: {type: array, minItems: 1, maxItems: 20")
.contains("required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId")
.contains("warehouseName is resolved and frozen server-side")
.contains("shippingCompanyId:", "departurePortCode:", "destinationPortCode:",
"placeOfDelivery:", "remark:", "PartyRequest:")
.contains("/{oceanBookingId}/cancel:", "/{oceanBookingId}/change-request:",
"/{oceanBookingId}/change-confirm:")
.contains("persists the booking directly as CONFIRMED")
.contains("RELEASED remains a separate later action")
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
.contains("DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid")
.contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true")
.contains("customerName: {type: string, maxLength: 128, nullable: true")
.contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text")
.contains("freightTerm: {type: string, maxLength: 32, description: Required free-text")
.contains("billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed")
.contains("estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA")
.doesNotContain("Must be omitted until release")
.doesNotContain("required: [customerId, customerName, channelCode, channelName");
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
.contains("Validate and permanently release a DRAFT")
.contains("containerRequirements:", "ContainerRequirementRequest:")
.contains("never requires or creates a real container number")
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
}
private static Method method(String name) {
@@ -33,9 +33,7 @@ class OceanBookingMapperContractTest {
.reduce("", (left, right) -> left + '\n' + right);
assertThat(sql)
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
.doesNotContain("SUBMITTED")
.doesNotContain("FAILED");
.contains("status='DRAFT' AND version=#{version}")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
}
}
@@ -22,39 +22,34 @@ class OceanBookingSqlContractTest {
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照'")
.contains("freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照'")
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status='CONFIRMED'")
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED')")
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
.contains("OLD.status = 'RELEASED'")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("('CONFIRMED','RELEASED') THEN")
.doesNotContain("('CONFIRMED','CHANGE_PENDING') THEN")
.contains("<> 'RELEASED' THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
}
@Test
void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception {
assertThat(sql())
.contains("reconcile_ocean_booking_legacy")
.contains("service_term/freight_term 为空的真实行")
.contains("status IN ('DRAFT','SUBMITTED','FAILED')")
.contains("不会伪造 CONFIRMED")
.contains("DROP CHECK chk_ocean_booking_status")
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
.contains("DROP COLUMN failure_reason")
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
.doesNotContain("UPDATE fl_ocean_booking SET status=");
}
@Test
@@ -62,11 +57,12 @@ class OceanBookingSqlContractTest {
assertThat(sql())
.contains("platform:firstleg:ocean-booking:list")
.contains("platform:firstleg:ocean-booking:create")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:delete")
.contains("platform:firstleg:ocean-booking:release")
.contains("platform:firstleg:ocean-booking:cancel")
.contains("platform:firstleg:ocean-booking:change")
.contains("platform:firstleg:ocean-booking:allocate")
.doesNotContain("platform:firstleg:ocean-booking:view");
.doesNotContain("platform:firstleg:ocean-booking:view",
"platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change");
}
private static String sql() throws Exception {
@@ -1,39 +1,27 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.BeanUtils;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -45,392 +33,223 @@ import static org.mockito.Mockito.*;
class OceanBookingServiceTest {
@Mock private OceanBookingRepository repository;
@Mock private PriceBookQueryRepository priceBooks;
@Mock private LoadingPickService loadingPickService;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
field.setAccessible(true);
field.set(service, objectMapper);
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(suppliers.booking(any(), any())).thenReturn(null);
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "Warehouse深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
anyLong(), anyString())).thenReturn(1);
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@Test
void rejectsCancellationAfterRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
void savesBlankDraftWithoutCreatingContainersOrAllocations() {
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
Detail result = service.create(9L, "admin", "draft-empty",
new CreateRequest(0, partialDraft(null)));
assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1",
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
.hasMessageContaining("不可逆");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), anyInt(), anyLong(), anyString());
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(inserted.get().getCustomerId()).isNull();
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
verify(repository, never()).insertContainer(any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
verifyNoInteractions(priceBooks, suppliers, warehouses);
}
@Test
void opensAllocatedContainerPdaTasksAfterRelease() {
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
confirmed.setVoyageNo("V001");
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
OceanBookingEntity released = booking("RELEASED", 4);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(12L);
when(repository.find(1L)).thenReturn(confirmed, released);
when(repository.release(eq(1L), any(ReleaseRequest.class), eq(9L), eq("admin"))).thenReturn(1);
when(repository.allocations(1L)).thenReturn(List.of(allocation));
var request = new ReleaseRequest(3, "BL-001", "VESSEL", "V001",
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
Detail result = service.release(1L, 9L, "admin", "release-1", request);
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
verify(loadingPickService).beginIfReady(12L, 9L, "admin");
}
@Test
void rejectsReleaseWhenTransportFieldsDifferFromRegisteredSnapshot() {
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
confirmed.setVoyageNo("V001");
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
when(repository.find(1L)).thenReturn(confirmed);
var request = new ReleaseRequest(3, "BL-CHANGED", "VESSEL", "V001",
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-mismatch", request))
.isInstanceOf(BizException.class).hasMessageContaining("必须与已登记");
verify(repository, never()).release(anyLong(), any(), anyLong(), anyString());
verifyNoInteractions(loadingPickService);
}
@Test
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
OceanBookingEntity current = booking("CONFIRMED", 5);
current.setChannelCode("CHANNEL-A");
current.setVesselName("OLD"); current.setVoyageNo("V1");
OceanBookingEntity pending = booking("CHANGE_PENDING", 6);
pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2");
when(repository.find(1L)).thenReturn(current, pending);
when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.requestChange(1L, 9L, "admin", "change-1",
new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), "改船"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD");
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
}
@Test
void rejectsDraftAndSubmittedStatusesFromOperationalList() {
for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
.isInstanceOf(BizException.class).hasMessageContaining("状态");
}
verify(repository, never()).page(any());
}
@Test
void passesOnlyConfirmedLifecycleStatusesToOperationalList() {
service.page(new Query(null, "CHANGE_PENDING", null, 1, 20));
org.mockito.ArgumentCaptor<Query> query = org.mockito.ArgumentCaptor.forClass(Query.class);
verify(repository).page(query.capture());
assertThat(query.getValue().status()).isEqualTo("CHANGE_PENDING");
}
@Test
void listsOnlyChannelsFromCurrentActivePriceBook() {
ChannelRow channel = channel("SEA-US", "美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
when(priceBooks.channelTaxModes(51L, 701L)).thenReturn(List.of());
var result = service.channels(701L);
assertThat(result).singleElement().satisfies(value -> {
assertThat(value.channelCode()).isEqualTo("SEA-US");
assertThat(value.channelName()).isEqualTo("美西海运");
assertThat(value.priceBookVersionId()).isEqualTo(51L);
});
}
@Test
void registersOfflineBookingDirectlyAsConfirmedWithCompleteTransportSnapshot() {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
AtomicReference<OceanBookingEntity> inserted = stubSuccessfulCreation();
Detail result = service.create(9L, "admin", "create-channel", new CreateRequest(0, draft()));
assertThat(inserted.get().getStatus()).isEqualTo("DRAFT");
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
assertThat(result.oceanBooking().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
assertThat(result.oceanBooking().getEstimatedSailingAt())
.isEqualTo(LocalDateTime.of(2026, 9, 1, 0, 0));
assertThat(result.oceanBooking().getEstimatedArrivalAt())
.isEqualTo(LocalDateTime.of(2026, 9, 20, 0, 0));
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
assertThat(history.getValue().getToStatus()).isEqualTo("CONFIRMED");
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
"admin");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insertContainer(containers.capture());
assertThat(containers.getValue().getContainerNo()).isEqualTo("MSCU-001");
assertThat(containers.getValue().getSourceType()).isEqualTo("OCEAN_BOOKING");
assertThat(containers.getValue().getRecordStatus()).isEqualTo("ACTIVE");
assertThat(containers.getValue().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository).insert(allocation.capture());
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(containers.getValue().getId());
}
@Test
void createsOneRequirementContainerAndAllocationPerRealContainerUnit() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
stubSuccessfulCreation();
DraftData source = draft();
List<ContainerRequest> requested = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-002", "20GP", "DRY", new BigDecimal("33"), new BigDecimal("28000")));
service.create(9L, "admin", "create-two-containers",
new CreateRequest(0, withContainers(source, requested)));
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
stubMasterData();
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
Detail result = service.create(9L, "admin", "draft-complete", new CreateRequest(0, completeDraft()));
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> requirements =
ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(anyLong(), requirements.capture());
assertThat(requirements.getValue()).hasSize(2)
.allSatisfy(requirement -> assertThat(requirement.getQuantity()).isEqualTo(1));
assertThat(requirements.getValue()).extracting(OceanBookingContainerRequirementEntity::getContainerType)
.containsExactly("40HC", "20GP");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository, times(2)).insertContainer(containers.capture());
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getContainerNo)
.containsExactly("MSCU-001", "MSCU-002");
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getId).doesNotHaveDuplicates();
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getCabinetNo).doesNotHaveDuplicates();
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository, times(2)).insert(allocations.capture());
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
.containsExactlyElementsOf(containers.getAllValues().stream().map(ContainerBookingEntity::getId).toList());
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
assertThat(captor.getValue()).singleElement().satisfies(value -> {
assertThat(value.getContainerType()).isEqualTo("40HC");
assertThat(value.getQuantity()).isEqualTo(2);
});
verify(repository, never()).insertContainer(any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
}
@Test
void rejectsDuplicateContainerNumbersWithinOneRegistrationBeforeWriting() {
DraftData source = draft();
List<ContainerRequest> duplicated = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
void updatesOnlyDraftWithExpectedVersion() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 3), booking("DRAFT", 4));
when(repository.updateDraft(any(), eq(3), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.updateDraft(1L, 9L, "admin", "update-1",
new UpdateRequest(3, partialDraft("备注")));
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-in-request",
new CreateRequest(0, withContainers(source, duplicated))))
.isInstanceOf(BizException.class).hasMessageContaining("不能重复");
verify(repository, never()).insert(any(OceanBookingEntity.class));
verifyNoInteractions(priceBooks);
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
verify(repository).replaceParties(1L, List.of());
verify(repository).replaceRequirements(1L, List.of());
}
@Test
void rejectsContainerNumberAlreadyRegisteredBeforeCreatingAggregate() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
when(repository.containerNoExists("MSCU-001")).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-existing",
new CreateRequest(0, draft())))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any(OceanBookingEntity.class));
void rejectsReleaseWhenPersistedAggregateIsIncompleteWithoutWriting() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 3));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-missing",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void savesEmptyCustomerSnapshotAndFreeTextTransportTerms() {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
stubSuccessfulCreation();
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
OceanBookingEntity released = completeBooking("RELEASED", 4);
when(repository.find(1L)).thenReturn(draft, released);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
ReleaseRequest request = new ReleaseRequest(3, "确认放舱");
Detail result = service.create(9L, "admin", "create-without-customer",
new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO")));
assertThat(result.oceanBooking().getCustomerId()).isNull();
assertThat(result.oceanBooking().getCustomerName()).isNull();
assertThat(result.oceanBooking().getServiceTerm()).isEqualTo("CY-CY");
assertThat(result.oceanBooking().getFreightTerm()).isEqualTo("NINGBO");
}
@Test
void rejectsPartialCustomerSnapshot() {
assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-id",
new CreateRequest(0, draft(100L, null, null, null))))
.isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空");
assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-name",
new CreateRequest(0, draft(null, "客户", null, null))))
.isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空");
verifyNoInteractions(priceBooks);
}
@Test
void rejectsMissingRequiredTransportTerms() {
assertThatThrownBy(() -> service.create(9L, "admin", "missing-service-term",
new CreateRequest(0, draft(100L, "客户", null, "NINGBO"))))
.isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空");
assertThatThrownBy(() -> service.create(9L, "admin", "blank-freight-term",
new CreateRequest(0, draft(100L, "客户", "CY-CY", " "))))
.isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空");
verifyNoInteractions(priceBooks);
}
@Test
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
DraftData source = draft();
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), source.billOfLadingNo(), source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
verifyNoInteractions(priceBooks);
}
@Test
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
DraftData source = draft();
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), null, source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
.hasMessageContaining("提单号、船名、航次、ETD和ETA不能为空");
verifyNoInteractions(priceBooks);
}
@Test
void replaysCreateAfterChannelIsNoLongerActive() throws Exception {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
CreateRequest request = new CreateRequest(0, draft());
when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
stubSuccessfulCreation();
Detail created = service.create(9L, "admin", "create-replay-channel", request);
Detail first = service.release(1L, 9L, "admin", "release-1", request);
assertThat(first.oceanBooking().getStatus()).isEqualTo("RELEASED");
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hashForCreate(request)); operation.setStatus("SUCCESS");
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(created));
when(repository.claimOperation(isNull(), eq(9L), eq("CREATE"), eq("create-replay-channel"), anyString()))
.thenReturn(false);
when(repository.operation(9L, "CREATE", "create-replay-channel")).thenReturn(operation);
operation.setRequestHash(hash("RELEASE", 1L, request));
operation.setStatus("SUCCESS");
operation.setResultJson(objectMapper.writeValueAsString(first));
when(repository.claimOperation(1L, 9L, "RELEASE", "release-1", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "RELEASE", "release-1")).thenReturn(operation);
Detail replay = service.create(9L, "admin", "create-replay-channel", request);
assertThat(replay.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
verify(priceBooks, times(1)).activeVersionId();
assertThat(service.release(1L, 9L, "admin", "release-1", request).oceanBooking().getStatus())
.isEqualTo("RELEASED");
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
private static ChannelRow channel(String code, String name) {
ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code);
value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value;
@Test
void releasedAggregateRejectsEditDeleteAndRepeatRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
assertThatThrownBy(() -> service.updateDraft(1L, 9L, "admin", "edit-released",
new UpdateRequest(4, partialDraft(null)))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.deleteDraft(1L, 9L, "admin", "delete-released",
new ActionRequest(4, null))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-again",
new ReleaseRequest(4, null))).isInstanceOf(BizException.class);
verify(repository, never()).deleteDraft(anyLong(), anyInt());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
private static DraftData draft() {
return draft(100L, "客户", "CY-CY", "NINGBO");
@Test
void deleteDraftKeepsIdempotencyDetachedFromDeletedAggregate() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 2));
when(repository.deleteDraft(1L, 2)).thenReturn(1);
service.deleteDraft(1L, 9L, "admin", "delete-1", new ActionRequest(2, "作废草稿"));
verify(repository).claimOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
verify(repository).completeOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
}
private static DraftData draft(Long customerId, String customerName, String serviceTerm, String freightTerm) {
List<PartyRequest> parties = List.of(
new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
List<ContainerRequest> containers = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
return new DraftData(customerId, customerName, "SEA-US", 701L, "BL-001",
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, containers);
@Test
void supportsOnlyDraftAndReleasedListStatuses() {
service.page(new Query(null, "DRAFT", null, 1, 20));
service.page(new Query(null, "RELEASED", null, 1, 20));
assertThatThrownBy(() -> service.page(new Query(null, "CONFIRMED", null, 1, 20)))
.isInstanceOf(BizException.class).hasMessageContaining("状态");
verify(repository, times(2)).page(any());
}
private static DraftData withContainers(DraftData source, List<ContainerRequest> containers) {
return new DraftData(source.customerId(), source.customerName(), source.channelCode(), source.warehouseId(),
source.billOfLadingNo(), source.shippingCompanyId(), source.shippingCompanyName(),
source.bookingAgentId(), source.bookingAgentName(), source.vesselName(), source.voyageNo(),
source.departurePortCode(), source.departurePortName(), source.destinationPortCode(),
source.destinationPortName(), source.placeOfDelivery(), source.serviceTerm(), source.freightTerm(),
source.estimatedSailingAt(), source.estimatedArrivalAt(), source.remark(), source.parties(), containers);
@Test
void releaseRejectsInvalidScheduleWithoutTransition() {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
draft.setEstimatedArrivalAt(draft.getEstimatedSailingAt().minusDays(1));
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-range",
new ReleaseRequest(3, null))).isInstanceOf(BizException.class).hasMessageContaining("不能早于");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
.when(repository).insert(any(OceanBookingEntity.class));
when(repository.find(anyLong())).thenAnswer(invocation -> {
OceanBookingEntity confirmed = new OceanBookingEntity();
BeanUtils.copyProperties(inserted.get(), confirmed);
confirmed.setStatus("CONFIRMED");
confirmed.setVersion(1);
return confirmed;
});
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
return inserted;
}
private String hashForCreate(CreateRequest request) throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, "CREATE", (Long) null, request);
private void stubMasterData() {
ChannelRow row = new ChannelRow();
row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运");
row.setPriceModel("FBA_WAREHOUSE_RATE");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE"));
}
private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
}
private static DraftData completeDraft() {
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司",
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null,
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
List.of(new ContainerRequirementRequest("40HC", "DRY", 2,
new BigDecimal("76"), new BigDecimal("28000"))));
}
private static OceanBookingEntity completeBooking(String status, int version) {
OceanBookingEntity value = booking(status, version);
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
return value;
}
private static OceanBookingEntity booking(String status, int version) {
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户");
value.setChannelCode("CHANNEL-A"); value.setVersion(version);
OceanBookingEntity value = new OceanBookingEntity();
value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setVersion(version);
return value;
}
private static List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity> parties() {
return List.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY").stream().map(type -> {
var value = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity();
value.setPartyType(type); value.setPartyName(type); return value;
}).toList();
}
private static OceanBookingContainerRequirementEntity requirement() {
var value = new OceanBookingContainerRequirementEntity();
value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY");
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76"));
value.setMaxWeightKg(new BigDecimal("28000")); return value;
}
private String hash(String action, Long id, Object request) throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, action, id, request);
}
}
@@ -26,15 +26,15 @@ class Phase3BookingTruckPermissionSqlContractTest {
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:cancel",
"platform:firstleg:ocean-booking:change");
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:delete");
private static final Set<String> OCEAN_SQL_PERMISSIONS = Set.of(
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:cancel",
"platform:firstleg:ocean-booking:change",
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:delete",
"platform:firstleg:ocean-booking:allocate");
private static final Set<String> TRUCK_PERMISSIONS = Set.of(
@@ -62,11 +62,13 @@ class Phase3FinalUnifiedSqlContractTest {
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception {
String sql = normalise(Files.readString(UNIFIED));
assertThat(sql)
.contains("reconcile_ocean_booking_legacy")
.contains("DROP CHECK chk_ocean_booking_status")
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
.contains("DROP COLUMN failure_reason")
.contains("不会伪造 CONFIRMED")
.contains("(status IN ('DRAFT','RELEASED'))")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:delete")
.doesNotContain("reconcile_ocean_booking_legacy")
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -32,12 +33,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -282,94 +282,83 @@ class ContainerServiceTest {
@Test
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksConfirmedBookingAfterPreloading() {
void linksReleasedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(repository.find(11L)).thenReturn(container, container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
when(oceanBookings.allocations(21L)).thenReturn(List.of());
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).hasActiveLoadingItems(11L);
verify(repository).consumeRealSource(41L, 11L, 9L, "admin");
verify(repository).mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin");
verify(oceanBookings).reassignAllocation(41L, 11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("首次关联允许消费已放舱的真实订舱柜")
void linksReleasedBooking() {
@DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
booking.setId(21L); booking.setStatus("DRAFT");
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已发布");
}
@Test
@DisplayName("订舱箱型需求达到数量上限时拒绝关联")
void rejectsRequirementOverAllocation() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setContainerType("40HC");
requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
requirement.setId(31L); requirement.setQuantity(1);
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
allocation.setRequirementId(31L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation));
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
verify(oceanBookings).reassignAllocation(41L, 11L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("数量上限");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("已合并的真实订舱柜不可再次关联")
void rejectsConsumedRealContainer() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity consumed = realContainer(41L, 21L, 31L);
consumed.setRecordStatus("MERGED"); consumed.setMergedIntoContainerBookingId(99L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(consumed);
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜")
void unlinksEmptySimulationDirectly() {
ContainerBookingEntity linked = booking("WAIT_LOADING", 2);
linked.setOceanBookingId(21L);
ContainerBookingEntity detached = booking("WAIT_LOADING", 3);
when(repository.find(11L)).thenReturn(linked, detached);
when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已被消费");
ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin",
new OceanBookingUnlinkRequest(2, "换绑"));
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("已有装柜计划的真实订舱柜不可被模拟柜消费")
void rejectsRealContainerWithLoadingPlan() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(repository.hasLoadingPlan(41L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已有装柜计划");
verifyNoInteractions(oceanBookings);
assertThat(result).isSameAs(detached);
verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin");
verify(oceanBookings).deleteAllocation(11L);
}
@Test
@@ -379,21 +368,12 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2)))
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ContainerBookingEntity realContainer(long id, long oceanBookingId, long requirementId) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(id); value.setContainerNo("MSCU7654321"); value.setCabinetNo("CAB-REAL");
value.setSourceType("OCEAN_BOOKING"); value.setRecordStatus("ACTIVE");
value.setOceanBookingId(oceanBookingId); value.setOceanBookingRequirementId(requirementId);
value.setWarehouseId(101L); value.setChannelCode("SEA-US"); value.setContainerType("40HC");
value.setCapacityCbm(new BigDecimal("76.0000")); value.setMaxWeightKg(new BigDecimal("28000.000"));
return value;
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
@@ -255,7 +256,7 @@ class SmartLoadingServiceTest {
assertThat(saved.getProductNameEn()).isEqualTo("English name");
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
@@ -382,7 +383,7 @@ class SmartLoadingServiceTest {
}
@Test
void updatesAndClearsWarehouseRemarkWithBothVersions() {
void updatesAndClearsBlankWarehouseRemarkWithBothVersions() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("旧仓库备注");
@@ -393,7 +394,7 @@ class SmartLoadingServiceTest {
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");
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(loadingPickService).lockMutable(1L);
@@ -404,6 +405,74 @@ class SmartLoadingServiceTest {
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@Test
void warehouseRemarkConflictProducesNoHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "新备注", 7L, "u")).thenReturn(0);
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).item(anyLong(), anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void exactWarehouseRemarkReplayIsReadOnly() {
String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0");
when(repository.operation("warehouse-replay"))
.thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash));
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u");
assertThat(result.version()).isEqualTo(3);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
}
@Test
void quantityAdjustmentPreservesWarehouseRemark() {
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.candidate(100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(2, 2, 0, "adjust-remark"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void preservesDownstreamCode() {
stubAssignment();