feat: enforce canonical container identity across regions

This commit is contained in:
2026-08-31 12:13:20 +08:00
parent b386f35ab4
commit 942dbace19
78 changed files with 1203 additions and 730 deletions
@@ -6,10 +6,8 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
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.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -96,30 +94,15 @@ public class PlatformContainerController {
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜")
public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
@Operation(summary="编辑货柜基础资料", description="仅待装柜或预装草稿时允许;不得写入真实集装箱号或内部柜次号")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
}
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), key, request));
}
@GetMapping("/{id}/clearance")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@Operation(summary = "查询货柜目的港清关资料")
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
/** ISO 6346 真实集装箱号的唯一规范化与校验入口。 */
public final class Iso6346ContainerNumber {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private Iso6346ContainerNumber() {
}
public static String require(String raw) {
String value = normalizeWhitespace(raw);
if (value == null || !value.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
throw invalid();
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = value.charAt(index);
int number = Character.isDigit(current)
? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
int expected = sum % 11 % 10;
if (value.charAt(10) - '0' != expected) throw invalid();
return value;
}
private static String normalizeWhitespace(String raw) {
if (raw == null) return null;
StringBuilder value = new StringBuilder(raw.length());
raw.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint))
.forEach(value::appendCodePoint);
String normalized = value.toString().toUpperCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static BizException invalid() {
return new BizException(ResultCode.BAD_REQUEST,
"真实集装箱号必须符合ISO 6346结构、设备类别和校验位");
}
}
@@ -46,7 +46,6 @@ public final class ContainerDtos {
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest(
@Size(max = 64) String containerNo,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@@ -80,19 +79,6 @@ public final class ContainerDtos {
@Valid @NotNull SaveRequest data
) {}
@Schema(name = "ContainerTransportUpdateRequest",
description = "拣货中阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version,
@NotBlank @Size(max = 500) String reason
) {
public TransportUpdateRequest(String containerNo, String voyageNo, Integer version) {
this(containerNo, voyageNo, version, "运输信息更新");
}
}
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
@@ -104,7 +90,4 @@ public final class ContainerDtos {
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
}
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
@@ -50,7 +51,7 @@ public final class LoadingListDtos {
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
@NotBlank @Size(max = 500) String operationReason
) {
}
@@ -1,25 +0,0 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter
@TableName("fl_container_transport_history")
public class ContainerTransportHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long containerBookingId;
private String beforeContainerNo;
private String afterContainerNo;
private String beforeVoyageNo;
private String afterVoyageNo;
private String operationId;
private String requestHash;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -123,26 +123,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Param("actualEmptyReportedAt") java.time.LocalDateTime actualEmptyReportedAt,
@Param("sourceVersion") long sourceVersion);
@Update("""
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
bill_of_lading_no=NULL, vessel_name=NULL, voyage_no=NULL,
shipping_company_id=NULL, shipping_company_name=NULL,
departure_port_code=NULL, departure_port_name=NULL,
destination_port_code=NULL, destination_port_name=NULL,
estimated_sailing_at=NULL, estimated_port_arrival_at=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
""")
int unlinkReleasedBooking(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo},
container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId},
@@ -169,22 +151,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_pick_task t
JOIN fl_loading_plan p ON p.id=t.loading_plan_id
WHERE p.container_booking_id=fl_container_booking.id))
""")
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},updated_by=#{operatorId},
@@ -1,11 +1,23 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
@Select("""
SELECT history.id,plan.container_booking_id containerBookingId,
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
JSON_UNQUOTE(JSON_EXTRACT(history.after_snapshot,'$.containerNo')) afterContainerNo,
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.voyageNo')) beforeVoyageNo,
JSON_UNQUOTE(JSON_EXTRACT(history.after_snapshot,'$.voyageNo')) afterVoyageNo,
history.operation_id operationId,history.operation_reason reason,
history.operator_id operatorId,history.operator_name operatorName,
history.occurred_at createdAt
FROM fl_loading_plan_history history
JOIN fl_loading_plan plan ON plan.id=history.loading_plan_id
WHERE plan.container_booking_id=#{id} AND history.action_type='UPDATE_PRELOAD_TRANSPORT'
ORDER BY history.occurred_at,history.id
""") List<TransportRow> transport(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id);
@@ -22,5 +34,8 @@ import java.util.List;
record ExceptionRow(Long id,String action,String reason,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt,String requestHash) {}
record PickTaskRow(Long id,Long loadingPlanId,Long containerBookingId,String taskStatus,Integer version) {}
record TimelineRow(String eventType,String detail,Long operatorId,String operatorName,java.time.LocalDateTime occurredAt) {}
record TransportRow(Long id,Long containerBookingId,String beforeContainerNo,String afterContainerNo,
String beforeVoyageNo,String afterVoyageNo,String operationId,String reason,
Long operatorId,String operatorName,java.time.LocalDateTime createdAt) {}
record PhotoRow(Long evidenceId,Long tenantId,Long inboundId,String evidenceType,Long fileId,String fileName,String status,java.time.LocalDateTime uploadedAt) {}
}
@@ -1,15 +0,0 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{bookingId} "
+ "AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1")
ContainerTransportHistoryEntity selectOperation(@Param("bookingId") long bookingId,
@Param("operatorId") long operatorId, @Param("operationId") String operationId);
}
@@ -3,7 +3,11 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> {
@Select("SELECT * FROM fl_inbound_outbox WHERE event_key=#{eventKey} LIMIT 1")
InboundOutboxEntity selectByEventKey(@Param("eventKey") String eventKey);
}
@@ -227,9 +227,18 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status IN ('EDITING', 'LOADING')
AND (plan_status = 'LOADING' OR preload_pushed_at IS NOT NULL)
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL
AND version = #{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=fl_loading_plan.id)
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
WHERE task.loading_plan_id=fl_loading_plan.id AND task.task_status<>'LOADING')
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_item item ON item.pick_task_id=task.id
WHERE task.loading_plan_id=fl_loading_plan.id
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
""")
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@@ -240,15 +249,44 @@ public interface LoadingListQueryMapper {
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status IN ('EDITING', 'LOADING')
AND (p.plan_status = 'LOADING' OR p.preload_pushed_at IS NOT NULL)
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id))
WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL
AND #{containerNo} IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=p.id)
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
WHERE task.loading_plan_id=p.id AND task.task_status<>'LOADING')
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_item item ON item.pick_task_id=task.id
WHERE task.loading_plan_id=p.id
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
""")
int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT CASE WHEN
EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=#{planId})
OR EXISTS (SELECT 1 FROM fl_loading_pick_task task
WHERE task.loading_plan_id=#{planId} AND task.task_status<>'LOADING')
OR EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_item item ON item.pick_task_id=task.id
WHERE task.loading_plan_id=#{planId}
AND (task.task_status<>'LOADING' OR item.item_status<>'PENDING'
OR item.picked_package_count<>0))
OR EXISTS (SELECT 1 FROM fl_loading_evidence evidence
JOIN fl_loading_plan_item plan_item
ON plan_item.account_id=evidence.tenant_id AND plan_item.inbound_id=evidence.inbound_id
WHERE plan_item.loading_plan_id=#{planId} AND plan_item.deleted=0
AND evidence.status='ACTIVE')
THEN 1 ELSE 0 END
""")
int hasPdaWorkStarted(@Param("planId") long planId);
@Update("""
UPDATE fl_preload_plan
SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName},
@@ -24,7 +24,7 @@ public final class CarrierScheduleQueryDtos {
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@NotNull @Positive Long customerId,
@NotNull @Positive Long oceanBookingId,
@Positive Long containerBookingId,
@NotNull @Positive Long containerBookingId,
@NotBlank @Size(max = 16) String carrierCode,
@NotBlank @Size(max = 32) String trackingType,
@NotBlank @Size(max = 128) String trackingNo,
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -36,10 +37,16 @@ public class CarrierScheduleNodeOutboxService {
public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact,
String containerNo) {
if (fact.getActualAt() == null) return;
String normalizedContainerNo = ContainerNumbers.normalizeRequired(containerNo);
if (!"CONTAINER_NO".equals(query.getTrackingType())
|| !normalizedContainerNo.equals(query.getTrackingNo())) {
throw new BizException(ResultCode.CONFLICT, "船节点任务跟踪柜号与跨区事件柜号不一致");
}
ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(),
string(query.getContainerBookingId()), normalizedContainerNo,
fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
@@ -61,7 +68,15 @@ public class CarrierScheduleNodeOutboxService {
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
row.setCreatedAt(LocalDateTime.now());
try { outbox.insert(row); } catch (DuplicateKeyException ignored) { }
try {
outbox.insert(row);
} catch (DuplicateKeyException duplicate) {
InboundOutboxEntity existing = outbox.selectByEventKey(eventKey);
if (existing == null || existing.getPayloadHash() == null
|| !existing.getPayloadHash().equalsIgnoreCase(payloadHash)) {
throw new BizException(ResultCode.CONFLICT, "相同eventKey对应的船节点柜号或负载发生漂移");
}
}
}
private String json(Object value) {
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
@@ -55,7 +56,7 @@ import java.util.Set;
@Service
public class CarrierScheduleQueryService {
static final int MAX_SLOT_ATTEMPTS = 3;
private static final Set<String> TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO");
private static final Set<String> TRACKING_TYPES = Set.of("CONTAINER_NO");
@Resource private CarrierScheduleQueryRepository repository;
@Resource private PortScheduleRepository portSchedules;
@@ -91,21 +92,15 @@ public class CarrierScheduleQueryService {
long operatorId, String operatorName) {
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (existing != null) return existing;
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId);
String trackingType;
String trackingNo;
if (StringUtils.hasText(container.getContainerNo())) {
trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim();
} else if (StringUtils.hasText(booking.getBillOfLadingNo())) {
trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim();
} else if (StringUtils.hasText(booking.getBookingNo())) {
trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim();
} else {
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
String trackingType = "CONTAINER_NO";
String trackingNo = normalizedContainerNo(container);
if (existing != null) {
requireFrozenTrackingIdentity(existing, trackingNo);
return existing;
}
PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo);
LocalDateTime now = now();
@@ -129,7 +124,10 @@ public class CarrierScheduleQueryService {
catch (DuplicateKeyException exception) {
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
if (concurrent != null) return concurrent;
if (concurrent != null) {
requireFrozenTrackingIdentity(concurrent, trackingNo);
return concurrent;
}
throw exception;
}
persistenceStage = "NODE_FACT";
@@ -352,15 +350,21 @@ public class CarrierScheduleQueryService {
Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request));
if (replay != null) return replay;
validateLinks(request.customerId(), request.oceanBookingId(), request.containerBookingId());
ContainerBookingEntity container = portSchedules.containerBooking(request.containerBookingId());
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
String carrierCode = adapters.normalize(request.carrierCode());
String trackingType = normalizeTrackingType(request.trackingType());
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, request.trackingNo());
String trackingNo = normalizedContainerNo(container);
if (!trackingNo.equals(request.trackingNo())) {
throw badRequest("trackingNo必须与柜次真实集装箱号一致");
}
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(request.customerId()); value.setOceanBookingId(request.oceanBookingId());
value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode);
value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim());
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
value.setStatus("READY"); value.setTriggerSource("MANUAL");
value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0);
@@ -516,10 +520,25 @@ public class CarrierScheduleQueryService {
private String requireContainerNo(long containerBookingId) {
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null || !StringUtils.hasText(container.getContainerNo())) {
if (container == null) {
throw conflict("跨区船节点交接要求柜次具备柜号");
}
return container.getContainerNo().trim().toUpperCase(Locale.ROOT);
return normalizedContainerNo(container);
}
private static String normalizedContainerNo(ContainerBookingEntity container) {
try {
return ContainerNumbers.normalizeRequired(container.getContainerNo());
} catch (IllegalArgumentException exception) {
throw badRequest("船节点查询要求柜次具备规范化真实集装箱号");
}
}
private static void requireFrozenTrackingIdentity(CarrierScheduleQueryEntity query, String containerNo) {
if (!"CONTAINER_NO".equals(query.getTrackingType())
|| !containerNo.equals(query.getTrackingNo())) {
throw conflict("船节点任务柜号与当前柜次真实集装箱号发生漂移");
}
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
@@ -598,7 +617,7 @@ public class CarrierScheduleQueryService {
private static String normalizeTrackingType(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!TRACKING_TYPES.contains(normalized)) {
throw badRequest("trackingType仅支持BOOKING_NO、BILL_OF_LADING或CONTAINER_NO");
throw badRequest("trackingType仅支持CONTAINER_NO");
}
return normalized;
}
@@ -33,9 +33,6 @@ public class ContainerRepository {
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
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,
int version, long operatorId, String operatorName) {
@@ -49,10 +46,6 @@ public class ContainerRepository {
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; }
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
}
public int syncReleasedTransport(long oceanBookingId, String billOfLadingNo, String vesselName,
String voyageNo, java.time.LocalDateTime estimatedSailingAt,
long operatorId, String operatorName) {
@@ -86,6 +86,10 @@ public class LoadingListRepository {
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public boolean hasPdaWorkStarted(long planId) {
return mapper.hasPdaWorkStarted(planId) > 0;
}
public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion,
long operatorId, String operatorName) {
return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion,
@@ -152,8 +152,8 @@ public class ContainerOverviewService {
private List<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) {
var result = new ArrayList<TimelineEntry>();
mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(),
value.getOperatorId(), value.getOperatorName(), value.getCreatedAt())));
mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.reason(),
value.operatorId(), value.operatorName(), value.createdAt())));
exceptionHistory.forEach(value -> result.add(new TimelineEntry(value.action(), value.reason(),
value.operatorId(), value.operatorName(), value.occurredAt())));
if (taskId != null) mapper.pickTimeline(taskId).forEach(value -> result.add(new TimelineEntry(
@@ -11,17 +11,11 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
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.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -29,30 +23,21 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
@Service
public class ContainerService {
@Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickRepository loadingPickRepository;
@Resource private LoadingPickService loadingPickService;
@Resource private PriceBookQueryUseCase priceBooks;
@Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
@@ -91,56 +76,44 @@ public class ContainerService {
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在");
String containerNo = Iso6346ContainerNumber.require(request.containerNo());
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
}
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking,
request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
try {
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo,
booking, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
return required(id);
}
@Transactional
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("模拟柜尚未关联真实订舱柜");
if (repository.unlinkReleasedBooking(id, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
oceanBookings.deleteAllocation(id);
return required(id);
}
private void requireFirstLinkMutable(ContainerBookingEntity container) {
SmartLoadingStatus value = status(container);
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|| trim(container.getContainerNo()) != null
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|| repository.hasDocuments(container.getId())) throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能关联订舱");
}
private void requireUnlinkMutable(ContainerBookingEntity container) {
if (status(container) != SmartLoadingStatus.WAIT_LOADING || repository.hasActiveLoadingItems(container.getId())
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|| repository.hasDocuments(container.getId())) throw conflict("已有排货、PDA、证据或单证事实,不能解除订舱关联");
}
@Transactional
public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName,
FullUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.WAIT_LOADING && status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
throw conflict("货柜已进入预装流程,基础资料不可完整编辑");
}
validateSchedule(request.data());
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
ensureUnique(trim(request.data().containerNo()), current.getCabinetNo(), id);
copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -153,98 +126,11 @@ public class ContainerService {
return required(id);
}
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) {
String operationId = transportOperationId(id, idempotencyKey);
String containerNo = trim(request.containerNo());
String voyageNo = trim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + id + '|' + request.version() + '|'
+ Objects.toString(containerNo, "") + '|' + Objects.toString(voyageNo, "") + '|'
+ request.reason().trim());
ContainerTransportHistoryEntity replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return required(id);
}
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return current;
}
if (Objects.equals(current.getContainerNo(), containerNo)
&& Objects.equals(current.getVoyageNo(), voyageNo)) {
return current;
}
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(id);
if (containerNo == null && plan != null && loadingPickRepository.taskByPlan(plan.getId()) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
}
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, voyageNo, request.version(),
operatorId, operatorName) != 1) {
ContainerTransportHistoryEntity concurrent = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent != null && requestHash.equals(concurrent.getRequestHash())) {
return required(id);
}
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity();
history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo());
history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo());
history.setAfterVoyageNo(voyageNo); history.setOperationId(operationId);
history.setRequestHash(requestHash); history.setReason(request.reason().trim());
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
try {
if (transportHistories != null) transportHistories.insert(history);
} catch (DuplicateKeyException exception) {
ContainerTransportHistoryEntity concurrent = transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent == null || !requestHash.equals(concurrent.getRequestHash())) {
throw conflict("Idempotency-Key已被并发用于不同运输字段请求");
}
}
ContainerBookingEntity updated = required(id);
loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName);
return updated;
}
private static String transportOperationId(long id, String value) {
String key = trim(value);
if (key == null) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空");
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key长度不能超过128");
return sha256("CONTAINER_TRANSPORT|" + id + '|' + key);
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256不可用", exception);
}
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) {
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
}
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
@@ -279,7 +165,6 @@ public class ContainerService {
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg());
v.setContainerNo(trim(r.containerNo()));
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType());
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -49,6 +50,7 @@ public class LoadingCompletedOutboxService {
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateContainerIdentity(rows);
validateCustomerSnapshots(rows);
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
@@ -67,7 +69,8 @@ public class LoadingCompletedOutboxService {
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()),
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload);
@@ -278,7 +281,36 @@ public class LoadingCompletedOutboxService {
value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion());
value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope));
value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now());
try { outboxMapper.insert(value); } catch (DuplicateKeyException ignored) { }
try {
outboxMapper.insert(value);
} catch (DuplicateKeyException duplicate) {
InboundOutboxEntity existing = outboxMapper.selectByEventKey(value.getEventKey());
if (existing == null || existing.getPayloadHash() == null
|| !existing.getPayloadHash().equalsIgnoreCase(value.getPayloadHash())) {
throw new BizException(ResultCode.CONFLICT, "相同eventKey对应的装柜完成事件柜号或负载发生漂移");
}
}
}
private static void validateContainerIdentity(List<Row> rows) {
Row first = rows.get(0);
String containerNo;
try {
containerNo = ContainerNumbers.requireCanonical(first.getContainerNo());
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.CONFLICT, "装柜完成前必须冻结真实集装箱号");
}
if (first.getContainerBookingId() == null) {
throw new BizException(ResultCode.CONFLICT, "装柜完成事件缺少来源柜次标识");
}
try {
boolean drift = rows.stream().anyMatch(row ->
!Objects.equals(first.getContainerBookingId(), row.getContainerBookingId())
|| !containerNo.equals(ContainerNumbers.requireCanonical(row.getContainerNo())));
if (drift) throw new BizException(ResultCode.CONFLICT, "装柜完成事件的柜次或集装箱号快照不一致");
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.CONFLICT, "装柜完成事件包含非规范真实集装箱号");
}
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
@@ -23,6 +23,8 @@ import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -62,6 +64,8 @@ public class LoadingListService {
@Resource
private LoadingPickRepository pickRepository;
@Resource
private ContainerRepository containerRepository;
@Resource
@Lazy
private LoadingPickService loadingPickService;
@Resource
@@ -397,41 +401,60 @@ public class LoadingListService {
resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) {
String containerNo = nullableTrim(request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId);
String reason = trim(request.operationReason());
if (!StringUtils.hasText(reason)) throw badRequest("纠错原因不能为空");
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
requireSameRequest(replay, requestHash);
String replayContainerNo = targetContainerNo(before, request.containerNo());
String replayHash = transportHash(planId, request.expectedVersion(), replayContainerNo,
nullableTrim(request.voyageNo()), reason);
requireSameRequest(replay, replayHash);
return repository.preloadDetail(before);
}
requireLoadingMutable(before);
requireTransportCorrectionMutable(before);
String containerNo = targetContainerNo(before, request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = transportHash(planId, request.expectedVersion(), containerNo, voyageNo, reason);
if (java.util.Objects.equals(before.getContainerNo(), containerNo)
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before);
}
if (containerNo == null && pickRepository.taskByPlan(planId) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
throw conflict("集装箱号已存在");
}
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试");
try {
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
}
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
json(values("requestHash", requestHash, "status", before.getPlanStatus(),
"containerNo", containerNo, "voyageNo", voyageNo,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason()));
reason);
loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName);
return repository.preloadDetail(requirePlan(planId));
}
private static String targetContainerNo(AggregateRow before, String requested) {
return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo())
: Iso6346ContainerNumber.require(requested);
}
private static String transportHash(long planId, int version, String containerNo,
String voyageNo, String reason) {
return sha256("TRANSPORT|" + planId + '|' + version + '|' + containerNo + '|'
+ voyageNo + '|' + reason);
}
@Transactional
@BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "原子确认装柜计划全部预装票据")
@@ -535,12 +558,16 @@ public class LoadingListService {
}
}
private static void requireLoadingMutable(AggregateRow row) {
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
private void requireTransportCorrectionMutable(AggregateRow row) {
if (!"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
}
if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) {
throw conflict("仅已推送的预装清单可维护运输字段");
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
if (!StringUtils.hasText(row.getContainerNo())) {
throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入");
}
if (repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
}
}
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -95,7 +95,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
@@ -190,6 +192,11 @@ BEGIN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED'));
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
END IF;
END$$
DELIMITER ;
CALL phase3_container_real_unit_columns();
@@ -96,7 +96,7 @@ components:
type: string
enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]
description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation.
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
TrackingType: {type: string, enum: [CONTAINER_NO], description: Only the normalized physical containerNo frozen by Firstleg is accepted; cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks.}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
StandardNode:
type: string
@@ -104,15 +104,15 @@ components:
ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]}
CreateRequest:
type: object
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo]
required: [expectedVersion, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo]
properties:
expectedVersion: {type: integer, minimum: 0, maximum: 0}
customerId: {type: integer, format: int64, minimum: 1}
oceanBookingId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true}
containerBookingId: {type: integer, format: int64, minimum: 1}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string, minLength: 1, maxLength: 128}
trackingNo: {type: string, minLength: 1, maxLength: 128, description: Normalized physical containerNo; must equal the linked Firstleg container snapshot.}
remark: {type: string, nullable: true, maxLength: 1000}
ManualResultRequest:
type: object
@@ -136,13 +136,13 @@ components:
reason: {type: string, minLength: 1, maxLength: 1000}
Task:
type: object
required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version]
required: [id, queryNo, customerId, oceanBookingId, containerBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version]
properties:
id: {type: integer, format: int64}
queryNo: {type: string}
customerId: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64, nullable: true}
containerBookingId: {type: integer, format: int64}
carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string}
@@ -295,16 +295,10 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo,服务端规范化并校验ISO 6346结构、设备类别和校验位。首次关联允许 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 操作身份}}
delete:
summary: 解除内部模拟柜的真实订舱关联
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}}
/api/platform/v1/containers/{id}:
get:
summary: 查询平台货柜详情
@@ -315,8 +309,8 @@ paths:
'200': {description: 成功或货柜不存在}
'403': {description: 无货柜查询权限}
put:
summary: 草稿阶段完整编辑货柜
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING(预装柜中)时允许
summary: 草稿阶段编辑货柜基础资料
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
requestBody:
@@ -327,21 +321,6 @@ paths:
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/transport:
patch:
summary: 修改集装箱号和航次
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读;相同幂等键精确重放零新增历史,相同业务字段为无效变更且不递增版本、不写历史
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'}
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/clearance:
get:
summary: 查询货柜目的港清关资料
@@ -1051,7 +1030,6 @@ components:
expectedArrivalDate: {type: string, format: date, nullable: true}
feeReviewStatus: {type: string, maxLength: 32, nullable: true}
channelName: {type: string, maxLength: 128, nullable: true}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64, nullable: true}
sailingDate: {type: string, format: date, nullable: true}
loaded: {type: boolean, nullable: true}
@@ -1652,14 +1630,6 @@ components:
properties:
version: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
ContainerTransportUpdateRequest:
type: object
required: [version, reason]
properties:
containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空}
voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500}
SmartLoadingPushRequest:
type: object
required: [expectedVersion]
@@ -1960,14 +1930,8 @@ components:
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号}
containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号;服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位}
expectedVersion: {type: integer, minimum: 0}
ContainerOceanBookingUnlinkRequest:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
ContainerFeeAdjustRequest:
type: object
additionalProperties: false
@@ -34,7 +34,9 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
patch:
summary: 修改预装运输信息
description: 已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。
description: >-
唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许;
containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
@@ -280,7 +282,7 @@ components:
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
ConfirmPreloadRequest:
type: object
required: [expectedVersion]
required: [expectedVersion, operationReason]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前装柜计划 version}
@@ -385,7 +387,7 @@ components:
required: [expectedVersion]
additionalProperties: false
properties:
containerNo: {type: string, maxLength: 64, nullable: true, description: 真实集装箱号;PDA任务创建前可空,任务创建后允许替换但禁止清空}
containerNo: {type: string, maxLength: 64, nullable: true, description: 新真实集装箱号;null表示保持当前值,空白字符串非法,禁止清空}
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
operationReason: {type: string, minLength: 1, maxLength: 500, description: 必填纠错原因}
@@ -22,9 +22,7 @@ class ContainerControllerContractTest {
assertPermission("detail", "platform:firstleg:container:list");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
assertPermission("overview", "platform:firstleg:container:list");
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
@@ -60,14 +58,15 @@ class ContainerControllerContractTest {
openapi.indexOf(" /openapi/v1/inbound-measurements:"));
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("校验ISO 6346结构、设备类别和校验位")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
.doesNotContain("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
@@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Map;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import static org.assertj.core.api.Assertions.assertThat;
@@ -67,8 +68,9 @@ class LoadingListControllerContractTest {
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.contains("已推送且仍为 EDITING")
.contains("不能清空已创建 PDA 任务的柜号")
.contains("唯一纠错入口")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm")
.contains("confirmationStatus")
.contains("canConfirm")
@@ -94,6 +96,16 @@ class LoadingListControllerContractTest {
assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull();
}
@Test
void transportCorrectionRequiresReasonAndKeepsNullContainerAsUnchanged() {
assertThat(component(TransportUpdateRequest.class, "operationReason").getAccessor()
.getAnnotation(NotBlank.class))
.isNotNull();
assertThat(component(TransportUpdateRequest.class, "containerNo").getAccessor()
.getAnnotation(NotBlank.class))
.isNull();
}
@Test
void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Iso6346ContainerNumberTest {
@Test
void normalizesAllowedWhitespaceAndLowercase() {
assertThat(Iso6346ContainerNumber.require(" ms cu 663987 0 "))
.isEqualTo("MSCU6639870");
}
@Test
void rejectsWrongEquipmentCategoryAndCheckDigit() {
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCA6639870"))
.isInstanceOf(BizException.class).hasMessageContaining("设备类别");
assertThatThrownBy(() -> Iso6346ContainerNumber.require("MSCU6639871"))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
}
}
@@ -22,12 +22,15 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED")
.contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD")
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
.contains("enum: [CONTAINER_NO]")
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
.contains("latestActualNode:")
.contains("latestActualAt:")
.contains("manual actual node is equal to or earlier than latestActualNode")
.contains("strictly greater sequence than latestActualNode")
.contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
.doesNotContain("/{queryId}/attempts:")
.doesNotContain("port-schedule-query:attempt");
}
@@ -19,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.springframework.dao.DuplicateKeyException;
class CarrierScheduleNodeOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
@@ -51,9 +54,35 @@ class CarrierScheduleNodeOutboxServiceTest {
assertThat(envelope.aggregateVersion()).isEqualTo(7);
assertThat(envelope.handoverVersion()).isEqualTo(8);
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY");
assertThat(envelope.payload().path("sourceContainerBookingId").asText())
.isEqualTo("2088000000000000003");
assertThat(envelope.payload().has("containerBookingId")).isFalse();
assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
}
@Test
void rejectsNonCanonicalOrDriftingTrackingIdentity() {
CarrierScheduleQueryEntity query = query();
query.setTrackingType("CONTAINER_NO"); query.setTrackingNo("zcsu7881970");
assertThatThrownBy(() -> service.publish(query, fact(), "ZCSU7881970"))
.hasMessageContaining("不一致");
query.setTrackingNo("MATU4646247");
assertThatThrownBy(() -> service.publish(query, fact(), "ZCSU7881970"))
.hasMessageContaining("不一致");
}
@Test
void duplicateEventKeyWithDifferentPayloadFailsClosed() {
InboundOutboxEntity existing = new InboundOutboxEntity(); existing.setPayloadHash("different");
when(mapper.insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
when(mapper.selectByEventKey(org.mockito.ArgumentMatchers.anyString())).thenReturn(existing);
assertThatThrownBy(() -> service.publish(query(), fact(), "ZCSU7881970"))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
@@ -70,7 +99,8 @@ class CarrierScheduleNodeOutboxServiceTest {
fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM");
fact.setProviderNodeCode("OUTBOUND_CONFIRM");
service.publish(query, fact, "CSNU1234567");
query.setTrackingNo("MATU4646247");
service.publish(query, fact, "MATU4646247");
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
@@ -84,6 +114,7 @@ class CarrierScheduleNodeOutboxServiceTest {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L);
value.setTrackingType("CONTAINER_NO"); value.setTrackingNo("ZCSU7881970");
value.setVersion(7); return value;
}
@@ -76,13 +76,17 @@ class CarrierScheduleQueryServiceTest {
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity());
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
var result = service.create(7L, "admin", "carrier-create",
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null));
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "CONTAINER_NO", "ZCSU7881970", null));
assertThat(result.query().query().carrierCode()).isEqualTo("ZIM");
assertThat(result.query().query().trackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.query().query().trackingNo()).isEqualTo("ZCSU7881970");
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().expired()).isTrue();
ArgumentCaptor<CarrierScheduleQueryEntity> created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class);
@@ -90,12 +94,57 @@ class CarrierScheduleQueryServiceTest {
assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
}
@Test
void outboundRequiresPhysicalContainerNumberAndNeverFallsBackToBookingOrBillOfLading() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setCarrierCode("ZIM"); booking.setBookingNo("OB-1"); booking.setBillOfLadingNo("BL-1");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(new com.baiyue.firstleg.entity.ContainerBookingEntity());
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"))
.isInstanceOf(BizException.class)
.hasMessageContaining("规范化真实集装箱号");
verify(repository, never()).insert(any(CarrierScheduleQueryEntity.class));
}
@Test
void manualCreateAlsoRejectsBookingOrBillOfLadingTrackingKeys() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
assertThatThrownBy(() -> service.create(7L, "admin", "carrier-create-bol",
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "BL-1", null)))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持CONTAINER_NO");
verify(repository, never()).insert(any(CarrierScheduleQueryEntity.class));
}
@Test
void outboundReplayFailsClosedWhenFrozenTrackingNumberDrifts() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
CarrierScheduleQueryEntity existing = new CarrierScheduleQueryEntity();
existing.setTrackingType("CONTAINER_NO"); existing.setTrackingNo("ZCSU0000000");
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(existing);
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"))
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
}
@Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -121,7 +170,7 @@ class CarrierScheduleQueryServiceTest {
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& fact.getActualAt() != null), eq("CSNU1234567"));
&& fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null
@@ -136,7 +185,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -176,7 +225,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -190,7 +239,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class));
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox, times(1)).publish(any(CarrierScheduleQueryEntity.class),
any(CarrierScheduleNodeFactEntity.class), eq("CSNU1234567"));
any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
verify(repository, times(1)).insert(any(CarrierScheduleQueryHistoryEntity.class));
}
@@ -199,7 +248,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -234,7 +283,7 @@ class CarrierScheduleQueryServiceTest {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
container.setContainerNo("ZCSU7881970");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
@@ -10,6 +10,8 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -73,21 +75,39 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
void guardsUpdatesInSql() {
String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.map(method -> method.getAnnotation(Update.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + "\n" + right);
assertThat(allSql)
@DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
String draftSql = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class,
long.class, String.class).getAnnotation(Update.class).value());
String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.filter(method -> method.getName().equals("linkReleasedBooking"))
.findFirst().orElseThrow().getAnnotation(Update.class).value());
assertThat(draftSql)
.contains("capacity_cbm")
.contains("p.plan_status!='EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.contains("version=#{expectedVersion}")
.doesNotContain("container_no=", "cabinet_no=")
.doesNotContain("&lt;")
.doesNotContain("&gt;");
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
"ocean_booking_id IS NULL");
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
.doesNotContain("updateTransport", "unlinkReleasedBooking");
}
@Test
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
for (String source : new String[] {
Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")),
Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))
}) {
assertThat(source)
.contains("国内唯一权威真实集装箱号")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("CONSTRAINT chk_container_booking_iso6346")
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
}
}
}
@@ -38,6 +38,15 @@ class ContainerOverviewSqlContractTest {
}
}
@Test void transportTimelineReadsTheSingleLoadingPlanCorrectionAudit() throws Exception {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("transport", long.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("fl_loading_plan_history", "action_type='UPDATE_PRELOAD_TRANSPORT'")
.contains("before_snapshot", "after_snapshot", "operation_reason")
.doesNotContain("fl_container_transport_history");
}
@Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception {
String development = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -76,16 +76,17 @@ class LoadingListQueryMapperContractTest {
String advance = sql("advance", Update.class);
assertThat(touch)
.contains("plan_status IN ('EDITING', 'LOADING')")
.contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("plan_status IN ('EDITING', 'LOADING')")
.contains("p.plan_status='EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item");
assertThat(advance)
@@ -177,7 +177,7 @@ class SmartLoadingSqlContractTest {
.contains("三期入库装柜角色授权未完整写入")
.contains("permission.permission_code IN (")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
.contains("permission.permission_code IN (\n 'firstleg:inbound:split'")
.contains("'firstleg:inbound:split', 'firstleg:inbound:preload'")
.contains(")) <> 18 THEN");
}
@@ -8,16 +8,8 @@ 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.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -49,14 +41,10 @@ import static org.mockito.Mockito.when;
class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService;
@MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@BeforeEach
@@ -140,152 +128,12 @@ class ContainerServiceTest {
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次");
.hasMessageContaining("基础资料不可完整编辑");
verify(repository, never()).updateDraft(any(), any(Integer.class),
any(Long.class), any(String.class));
}
@Test
@DisplayName("预装柜中和预装柜完成只更新集装箱号与航次")
void updatesTransportWhitelistAfterPush() {
for (String status : new String[] {"LOADING"}) {
ContainerBookingEntity current = booking(status, 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
service.updateTransport(11L, 9L, "admin", "transport-1",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
}
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
verify(transportHistories).insert(history.capture());
assertThat(history.getValue().getBeforeContainerNo()).isEqualTo("MSCU1234567");
assertThat(history.getValue().getAfterVoyageNo()).isEqualTo("V002");
assertThat(history.getValue().getOperationId()).hasSize(64);
assertThat(history.getValue().getRequestHash()).hasSize(64);
}
@Test
@DisplayName("运输字段没有有效变化时不递增版本且不写历史")
void ignoresTransportNoOp() {
ContainerBookingEntity current = booking("LOADING", 2);
current.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current);
ContainerBookingEntity result = service.updateTransport(11L, 9L, "admin", "transport-noop",
new TransportUpdateRequest(" MSCU1234567 ", " V002 ", 2));
assertThat(result).isSameAs(current);
verify(repository, never()).updateTransport(anyLong(), any(), any(), anyInt(), anyLong(), any());
verify(transportHistories, never()).insert(any(ContainerTransportHistoryEntity.class));
verifyNoInteractions(loadingPickService);
}
@Test
@DisplayName("运输字段精确幂等重放零新增")
void replaysTransportWithoutSecondMutationOrHistory() {
ContainerBookingEntity current = booking("LOADING", 2);
ContainerBookingEntity updated = booking("LOADING", 3);
updated.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current, updated, updated);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
TransportUpdateRequest request = new TransportUpdateRequest("MSCU1234567", "V002", 2);
service.updateTransport(11L, 9L, "admin", "transport-replay", request);
verify(transportHistories).insert(history.capture());
when(transportHistories.selectOperation(eq(11L), eq(9L), anyString()))
.thenReturn(history.getValue());
ContainerBookingEntity replay = service.updateTransport(
11L, 9L, "admin", "transport-replay", request);
assertThat(replay.getVersion()).isEqualTo(3);
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
verify(transportHistories, org.mockito.Mockito.times(1))
.insert(any(ContainerTransportHistoryEntity.class));
}
@Test
@DisplayName("预装清单推送后再绑定集装箱号会创建PDA拣货任务")
void startsPdaTaskWhenContainerIsBoundAfterPreloadPush() {
ContainerBookingEntity current = booking("EDITING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
service.updateTransport(11L, 9L, "admin", "transport-2",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
}
@Test
@DisplayName("PDA任务创建后替换真实柜号不重同步任务明细")
void doesNotResynchronizeExistingPdaTaskWhenContainerNumberChanges() {
ContainerBookingEntity current = booking("EDITING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU7654321", "V002", 2, 9L, "admin"))
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
service.updateTransport(11L, 9L, "admin", "transport-3",
new TransportUpdateRequest("MSCU7654321", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
}
@Test
@DisplayName("PDA任务创建后禁止清空真实集装箱号")
void rejectsClearingContainerNumberAfterPdaTaskStarts() {
ContainerBookingEntity current = booking("LOADING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
when(repository.find(11L)).thenReturn(current);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-4",
new TransportUpdateRequest(null, "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test
@DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() {
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-5",
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("全部只读");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test
@DisplayName("旧版本编辑请求触发乐观锁冲突")
void rejectsStaleVersion() {
@@ -339,6 +187,7 @@ class ContainerServiceTest {
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksReleasedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
OceanBookingEntity booking = new OceanBookingEntity();
@@ -349,20 +198,44 @@ class ContainerServiceTest {
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2));
new OceanBookingLinkRequest(21L, 31L, " ms cu 663987 0 ", 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).hasActiveLoadingItems(11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(booking), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
stubLinkContext();
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联规范化后按全局唯一真实柜号拒绝冲突")
void rejectsNormalizedContainerNumberConflict() {
stubLinkContext();
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
}
@Test
@DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() {
@@ -385,13 +258,14 @@ class ContainerServiceTest {
@DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
OceanBookingEntity booking = new OceanBookingEntity();
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)))
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已发布");
}
@@ -399,6 +273,7 @@ class ContainerServiceTest {
@DisplayName("订舱箱型需求达到数量上限时拒绝关联")
void rejectsRequirementOverAllocation() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
@@ -409,7 +284,7 @@ class ContainerServiceTest {
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("数量上限");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
@@ -418,6 +293,7 @@ class ContainerServiceTest {
private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel,
String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US");
container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
@@ -431,38 +307,38 @@ class ContainerServiceTest {
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@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);
ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin",
new OceanBookingUnlinkRequest(2, "换绑"));
assertThat(result).isSameAs(detached);
verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin");
verify(oceanBookings).deleteAllocation(11L);
private void stubLinkContext() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US");
container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
}
@Test
@DisplayName("首次关联遇到有效装柜证据时拒绝")
void rejectsLinkWhenEvidenceExists() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setContainerNo(null);
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
@@ -485,7 +361,7 @@ class ContainerServiceTest {
}
private static SaveRequest request() {
return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮",
return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
@@ -72,10 +72,36 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(first.payload().at("/sourceContainerBookingId").asText()).isEqualTo("91");
assertThat(first.payload().has("containerBookingId")).isFalse();
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@Test void requiresFrozenPhysicalContainerNumberBeforePublishing() {
Row value = row(11, 100, 101); value.setContainerNo(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("真实集装箱号");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void rejectsNonCanonicalContainerNumberAndDuplicateEventKeyPayloadDrift() {
Row value = row(11, 100, 101); value.setContainerNo(" zcsu7881970 ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("真实集装箱号");
value.setContainerNo("ZCSU7881970");
InboundOutboxEntity existing = new InboundOutboxEntity(); existing.setPayloadHash("different");
org.mockito.Mockito.when(outbox.insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)))
.thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
org.mockito.Mockito.when(outbox.selectByEventKey(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(existing);
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() {
Row value = row(11, 100, 101);
value.setSkuCode(null);
@@ -295,7 +321,7 @@ class LoadingCompletedOutboxServiceTest {
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setAggregateVersion(8); r.setContainerNo("ZCSU7881970"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setWarehouseName("WH"); r.setCustomerName("客户-" + account);
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
@@ -19,6 +19,7 @@ import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -36,6 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -53,6 +55,8 @@ class LoadingListServiceTest {
@MockBean
private LoadingPickRepository pickRepository;
@MockBean
private ContainerRepository containerRepository;
@MockBean
private LoadingPickService loadingPickService;
@MockBean
private LoadingDocumentFileClient documentFiles;
@@ -80,40 +84,43 @@ class LoadingListServiceTest {
@Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("LOADING", 3);
before.setContainerNo("OLD");
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
before.setVoyageNo("001E");
AggregateRow after = row("LOADING", 4);
after.setContainerNo("CSLU1234567");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
when(repository.updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
new TransportUpdateRequest(" cs qu 305438 3 ", "023E", 3, "纠错箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
verify(repository).updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("补录箱号"));
eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("纠错箱号"));
}
@Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("LOADING", 3);
before.setContainerNo("CSLU1234567");
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.preloadDetail(before)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "noop-key",
new TransportUpdateRequest(" CSLU1234567 ", " 023E ", 3, "重复保存"));
new TransportUpdateRequest(" ms cu 663987 0 ", " 023E ", 3, "重复保存"));
verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(),
any(), any(), anyLong(), any());
@@ -123,29 +130,32 @@ class LoadingListServiceTest {
}
@Test
void pushedEditingPreloadCanReceiveContainerNumber() {
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSLU1234567");
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", null, 9L, "operator")).thenReturn(1);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"));
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"));
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
@Test
void editingPlanCannotReceiveTransportBeforePush() {
when(repository.aggregate(10L)).thenReturn(row("EDITING", 3));
AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号")))
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")))
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(),
@@ -159,7 +169,7 @@ class LoadingListServiceTest {
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
new TransportUpdateRequest("CSQU3054383", "023E", 5, "完成后纠错")))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -167,22 +177,73 @@ class LoadingListServiceTest {
}
@Test
void shouldRejectClearingContainerNumberAfterPdaTaskStarts() {
AggregateRow before = row("LOADING", 3);
when(repository.history(anyString())).thenReturn(null);
void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(pickRepository.taskByPlan(10L)).thenReturn(new com.baiyue.firstleg.entity.LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(null, "023E", 3, "清空测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
new TransportUpdateRequest(" ", "023E", 3, "清空测试")))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).updateTransport(eq(10L), eq(3),
org.mockito.ArgumentMatchers.nullable(String.class),
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
org.mockito.ArgumentMatchers.nullable(String.class));
}
@Test
void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "claimed-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "领取后纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("已领取");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
}
@Test
void loadingPlanAndMissingInitialOceanBookingNumberBothRejectCorrection() {
AggregateRow loading = row("LOADING", 3);
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
loading.setContainerNo("MSCU6639870");
AggregateRow missing = row("EDITING", 3);
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.aggregate(10L)).thenReturn(loading, missing);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "loading-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "作业后纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("PDA尚未开始");
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "首次补录")))
.isInstanceOf(BizException.class).hasMessageContaining("首次写入");
}
@Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "unique-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(false);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"))
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_container_no"));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "race-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "并发唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
}
@Test
void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2);
@@ -220,23 +281,26 @@ class LoadingListServiceTest {
@Test
void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("LOADING", 3);
AggregateRow after = row("LOADING", 4);
after.setContainerNo("CSLU1234567");
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
when(repository.updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
TransportUpdateRequest request = new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错");
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
eq("operator"), eq(null));
eq("EDITING"), eq("EDITING"), anyString(), snapshot.capture(), eq(9L),
eq("operator"), eq("纠错"));
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(snapshot.getValue());
when(repository.history(anyString())).thenReturn(replay);
@@ -244,7 +308,24 @@ class LoadingListServiceTest {
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
verify(repository, times(1)).updateTransport(10L, 3, "CSQU3054383", "023E", 9L, "operator");
}
@Test
void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reused-key",
new TransportUpdateRequest("CSQU3054383", "023E", 3, "纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
}
@Test
@@ -48,7 +48,7 @@ class OverseasContainerEventConsumerTest {
Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z");
consumer.consume(event("00000000-0000-4000-8000-000000000001", 7,
new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null)));
new OverseasContainerUnloadedV1("1001", "ZCSU7881970", completedAt, uploadedAt, null)));
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")),
eq(LocalDateTime.parse("2026-08-20T02:30:00")),
@@ -62,7 +62,7 @@ class OverseasContainerEventConsumerTest {
@Test
void repeatedEventWithSameHashDoesNotApplyAgain() {
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload);
ContainerEventInboxEntity existing = new ContainerEventInboxEntity();
@@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest {
@Test
void rejectsTamperedPayloadHash() {
EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7,
new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"),
new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"),
Instant.parse("2026-08-20T02:30:00Z"), null));
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
@@ -102,7 +102,7 @@ class OverseasContainerEventConsumerTest {
private ContainerBookingEntity container() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(1001L); value.setContainerNo("MSCU1234567");
value.setId(1001L); value.setContainerNo("ZCSU7881970");
return value;
}
}