feat: enforce canonical container identity across regions
This commit is contained in:
+1
-18
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
-25
@@ -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) {}
|
||||
}
|
||||
|
||||
-15
@@ -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},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+17
-2
@@ -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) {
|
||||
|
||||
+37
-18
@@ -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());
|
||||
|
||||
+34
-2
@@ -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任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user