优化
This commit is contained in:
@@ -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;
|
||||
|
||||
+2
-3
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+3
-9
@@ -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); }
|
||||
}
|
||||
|
||||
+4
-51
@@ -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}}}
|
||||
|
||||
+5
-2
@@ -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]")
|
||||
|
||||
+5
@@ -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}")
|
||||
|
||||
+8
-26
@@ -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) {
|
||||
|
||||
+2
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-17
@@ -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 {
|
||||
|
||||
+169
-350
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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(
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user