优化
This commit is contained in:
@@ -100,9 +100,9 @@ public final class ContainerDtos {
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
|
||||
|
||||
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将内部模拟柜关联到真实订舱箱型分配")
|
||||
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
|
||||
@NotNull @Positive Long requirementId, @NotNull @PositiveOrZero Integer expectedVersion) {}
|
||||
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将内部模拟柜原地合并为指定真实订舱柜")
|
||||
public record OceanBookingLinkRequest(@NotNull @Positive Long realContainerBookingId,
|
||||
@NotNull @PositiveOrZero Integer expectedVersion) {}
|
||||
|
||||
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
|
||||
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
|
||||
@@ -20,6 +20,9 @@ public class ContainerBookingEntity {
|
||||
private String bookingNo;
|
||||
private String containerNo;
|
||||
private String cabinetNo;
|
||||
private String sourceType;
|
||||
private String recordStatus;
|
||||
private Long mergedIntoContainerBookingId;
|
||||
private String channelCode;
|
||||
private String channelName;
|
||||
private Long oceanBookingId;
|
||||
|
||||
@@ -21,7 +21,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
) THEN 'WAIT_LOADING'
|
||||
ELSE p.plan_status END AS plan_status
|
||||
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
|
||||
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
|
||||
WHERE b.record_status = 'ACTIVE'
|
||||
AND (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
|
||||
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
|
||||
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
|
||||
AND (#{query.customerId} IS NULL OR b.customer_id = #{query.customerId} OR EXISTS (
|
||||
@@ -68,13 +69,90 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
) THEN 'WAIT_LOADING'
|
||||
ELSE p.plan_status END AS plan_status
|
||||
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
|
||||
WHERE b.id = #{id}
|
||||
WHERE b.id = #{id} AND b.record_status = 'ACTIVE'
|
||||
""")
|
||||
ContainerBookingEntity selectBooking(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
|
||||
ContainerBookingEntity selectBookingForUpdate(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
|
||||
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM fl_container_booking WHERE merged_into_container_booking_id=#{targetId} AND record_status='MERGED' FOR UPDATE")
|
||||
ContainerBookingEntity selectMergedSourceForUpdate(@Param("targetId") long targetId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET record_status='MERGED',
|
||||
merged_into_container_booking_id=#{targetId}, container_no=NULL,
|
||||
updated_by=#{operatorId}, updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='ACTIVE'
|
||||
AND merged_into_container_booking_id IS NULL AND container_no IS NOT NULL
|
||||
""")
|
||||
int consumeRealSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking target
|
||||
JOIN fl_container_booking source ON source.id=#{sourceId}
|
||||
SET target.container_no=#{containerNo}, target.ocean_booking_id=source.ocean_booking_id,
|
||||
target.ocean_booking_requirement_id=source.ocean_booking_requirement_id,
|
||||
target.customer_id=source.customer_id, target.customer_name=source.customer_name,
|
||||
target.bill_of_lading_no=source.bill_of_lading_no,
|
||||
target.vessel_name=source.vessel_name, target.voyage_no=source.voyage_no,
|
||||
target.shipping_company_id=source.shipping_company_id,
|
||||
target.shipping_company_name=source.shipping_company_name,
|
||||
target.departure_port_code=source.departure_port_code,
|
||||
target.departure_port_name=source.departure_port_name,
|
||||
target.destination_port_code=source.destination_port_code,
|
||||
target.destination_port_name=source.destination_port_name,
|
||||
target.estimated_sailing_at=source.estimated_sailing_at,
|
||||
target.estimated_port_arrival_at=source.estimated_port_arrival_at,
|
||||
target.updated_by=#{operatorId}, target.updated_by_name=#{operatorName},
|
||||
target.updated_at=CURRENT_TIMESTAMP(3), target.version=target.version+1
|
||||
WHERE target.id=#{targetId} AND target.version=#{expectedVersion}
|
||||
AND target.source_type='SIMULATION' AND target.record_status='ACTIVE'
|
||||
AND target.ocean_booking_id IS NULL AND source.record_status='MERGED'
|
||||
AND source.merged_into_container_booking_id=target.id
|
||||
""")
|
||||
int mergeRealSource(@Param("targetId") long targetId, @Param("sourceId") long sourceId,
|
||||
@Param("containerNo") String containerNo,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
|
||||
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
|
||||
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 clearMergedLink(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET record_status='ACTIVE',
|
||||
merged_into_container_booking_id=NULL, container_no=#{containerNo},
|
||||
updated_by=#{operatorId}, updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='MERGED'
|
||||
AND merged_into_container_booking_id=#{targetId} AND container_no IS NULL
|
||||
""")
|
||||
int restoreMergedSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
|
||||
@Param("containerNo") String containerNo,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET
|
||||
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
|
||||
@@ -99,10 +177,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
""")
|
||||
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_container_booking SET ocean_booking_id=#{value.oceanBookingId},ocean_booking_requirement_id=#{value.oceanBookingRequirementId},customer_id=#{value.customerId},customer_name=#{value.customerName},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{value.id} AND version=#{expectedVersion} AND ocean_booking_id IS NULL")
|
||||
int updateLink(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_container_booking SET ocean_booking_id=NULL,ocean_booking_requirement_id=NULL,customer_id=NULL,customer_name=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}")
|
||||
int clearLink(@Param("id") long id, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_plan WHERE container_booking_id=#{id}") int hasLoadingPlan(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0") int hasActiveLoadingItems(@Param("id") long id);
|
||||
@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);
|
||||
|
||||
@@ -24,7 +24,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
|
||||
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
WHERE booking.container_no = #{containerNo} AND task.task_status = 'LOADING'
|
||||
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
|
||||
AND task.task_status = 'LOADING'
|
||||
AND booking.warehouse_id = #{warehouseId}
|
||||
LIMIT 1
|
||||
""")
|
||||
@@ -33,12 +34,14 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
|
||||
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
|
||||
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
|
||||
+ "WHERE task.task_status='LOADING' AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
|
||||
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
|
||||
+ "AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
|
||||
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
|
||||
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
|
||||
+ "WHERE task.task_status='LOADING' AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
|
||||
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
|
||||
+ "AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
|
||||
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("""
|
||||
@@ -56,7 +59,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
|
||||
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
|
||||
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
|
||||
WHERE task.task_status = 'LOADING'
|
||||
WHERE task.task_status = 'LOADING' AND booking.record_status = 'ACTIVE'
|
||||
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.baiyue.firstleg.oceanbooking.dto;
|
||||
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
|
||||
@@ -33,17 +31,16 @@ public final class OceanBookingDtos {
|
||||
@Size(max = 128) String contactName,
|
||||
@Size(max = 64) String contactPhone) {}
|
||||
|
||||
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
|
||||
@NotBlank @Size(max = 32) String containerKind,
|
||||
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
|
||||
@NotNull @Positive Integer quantity) {}
|
||||
public record ContainerRequest(@NotBlank @Size(max = 64) String containerNo,
|
||||
@NotBlank @Size(max = 32) String containerType,
|
||||
@NotBlank @Size(max = 32) String containerKind,
|
||||
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
|
||||
|
||||
public record DraftData(@Positive Long customerId,
|
||||
@Size(max = 128) String customerName,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseName,
|
||||
@NotBlank @Size(max = 128) String billOfLadingNo,
|
||||
@Positive Long shippingCompanyId,
|
||||
@Size(max = 128) String shippingCompanyName,
|
||||
@@ -62,7 +59,7 @@ public final class OceanBookingDtos {
|
||||
@NotNull LocalDateTime estimatedArrivalAt,
|
||||
@Size(max = 1000) String remark,
|
||||
@Valid @NotEmpty @Size(max = 3) List<PartyRequest> parties,
|
||||
@Valid @NotEmpty @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
|
||||
@Valid @NotEmpty @Size(max = 20) List<ContainerRequest> containers) {}
|
||||
|
||||
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Valid @NotNull DraftData data) {}
|
||||
@@ -81,10 +78,15 @@ public final class OceanBookingDtos {
|
||||
LocalDateTime estimatedSailingAt,
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
|
||||
Long requirementId, Long allocationId, String containerType,
|
||||
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
|
||||
Long warehouseId, String warehouseName, String channelCode,
|
||||
String channelName, Long customerId, String customerName) {}
|
||||
|
||||
public record Detail(OceanBookingEntity oceanBooking,
|
||||
List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> containerRequirements,
|
||||
List<OceanBookingContainerAllocationEntity> containerAllocations,
|
||||
List<RealContainer> containers,
|
||||
List<OceanBookingStatusHistoryEntity> histories) {}
|
||||
|
||||
public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
|
||||
|
||||
+6
-1
@@ -2,4 +2,9 @@ package com.baiyue.firstleg.oceanbooking.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper<OceanBookingContainerAllocationEntity> {}
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper<OceanBookingContainerAllocationEntity> {
|
||||
@Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}")
|
||||
int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId);
|
||||
}
|
||||
|
||||
+28
@@ -4,14 +4,19 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.mapper.*;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class OceanBookingRepository {
|
||||
@@ -49,6 +54,22 @@ public class OceanBookingRepository {
|
||||
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
|
||||
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
|
||||
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
|
||||
public List<RealContainer> containers(long id) {
|
||||
Map<Long, OceanBookingContainerRequirementEntity> requirementById = requirements(id).stream()
|
||||
.collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity()));
|
||||
return allocations(id).stream().map(allocation -> {
|
||||
OceanBookingContainerRequirementEntity requirement = requirementById.get(allocation.getRequirementId());
|
||||
ContainerBookingEntity container = containerBookings.find(allocation.getContainerBookingId());
|
||||
if (requirement == null || container == null) {
|
||||
throw new IllegalStateException("订舱真实柜聚合关系不完整");
|
||||
}
|
||||
return new RealContainer(container.getId(), container.getCabinetNo(), container.getContainerNo(),
|
||||
requirement.getId(), allocation.getId(), requirement.getContainerType(),
|
||||
requirement.getContainerKind(), requirement.getCapacityCbm(), requirement.getMaxWeightKg(),
|
||||
container.getWarehouseId(), container.getWarehouseName(), container.getChannelCode(),
|
||||
container.getChannelName(), container.getCustomerId(), container.getCustomerName());
|
||||
}).toList();
|
||||
}
|
||||
public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) {
|
||||
return allocations.selectOne(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>()
|
||||
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
|
||||
@@ -61,7 +82,14 @@ public class OceanBookingRepository {
|
||||
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
|
||||
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
|
||||
}
|
||||
public boolean containerNoExists(String containerNo) {
|
||||
return containerBookings.containerNoExists(containerNo, null);
|
||||
}
|
||||
public void insertContainer(ContainerBookingEntity value) { containerBookings.insert(value); }
|
||||
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
|
||||
public int reassignAllocation(long sourceId, long targetId) {
|
||||
return allocations.reassign(sourceId, targetId);
|
||||
}
|
||||
public void deleteAllocation(long containerBookingId) { allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); }
|
||||
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
|
||||
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
|
||||
|
||||
+101
-7
@@ -2,8 +2,11 @@ package com.baiyue.firstleg.oceanbooking.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
|
||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
@@ -18,6 +21,8 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@@ -41,6 +46,9 @@ public class OceanBookingService {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
String status = trim(query.status());
|
||||
@@ -75,24 +83,34 @@ public class OceanBookingService {
|
||||
String hash = hash(action, null, request);
|
||||
Detail replay = claimOrReplay(null, operatorId, action, key, hash);
|
||||
if (replay != null) return replay;
|
||||
ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode());
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
|
||||
ChannelOption channel = requireActiveChannel(warehouse.id(), request.data().channelCode());
|
||||
var bookingAgent = suppliers.booking(request.data().bookingAgentId(), request.data().bookingAgentName());
|
||||
request.data().containers().forEach(container -> {
|
||||
if (repository.containerNoExists(container.containerNo().trim())) {
|
||||
throw conflict("集装箱号已存在:" + container.containerNo().trim());
|
||||
}
|
||||
});
|
||||
OceanBookingEntity value = new OceanBookingEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setBookingNo("OB" + value.getId());
|
||||
// Child-row triggers still require DRAFT while the aggregate is assembled.
|
||||
// The status transition is in the same transaction, so callers only observe CONFIRMED.
|
||||
value.setStatus(OceanBookingStatus.DRAFT.name());
|
||||
copy(value, request.data(), channel.channelName(), bookingAgent == null ? null : bookingAgent.name());
|
||||
copy(value, request.data(), warehouse.warehouseName(), channel.channelName(),
|
||||
bookingAgent == null ? null : bookingAgent.name());
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
repository.insert(value);
|
||||
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
|
||||
repository.replaceRequirements(value.getId(), requirements(value.getId(), request.data().containerRequirements()));
|
||||
List<OceanBookingContainerRequirementEntity> requirements = requirements(
|
||||
value.getId(), request.data().containers());
|
||||
repository.replaceRequirements(value.getId(), requirements);
|
||||
if (repository.transition(value.getId(), OceanBookingStatus.DRAFT.name(),
|
||||
OceanBookingStatus.CONFIRMED.name(), null, 0, operatorId, operatorName) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
createRealContainers(value, requirements, request.data().containers(), operatorId, operatorName);
|
||||
OceanBookingEntity confirmed = required(value.getId());
|
||||
addHistory(value.getId(), OceanBookingStatus.DRAFT.name(), OceanBookingStatus.CONFIRMED.name(), action,
|
||||
null, value, confirmed, operatorId, operatorName);
|
||||
@@ -196,7 +214,7 @@ public class OceanBookingService {
|
||||
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
|
||||
}
|
||||
|
||||
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.allocations(value.getId()), repository.histories(value.getId())); }
|
||||
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.containers(value.getId()), repository.histories(value.getId())); }
|
||||
private OceanBookingEntity required(long id) { OceanBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); return value; }
|
||||
private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
||||
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
|
||||
@@ -219,6 +237,13 @@ public class OceanBookingService {
|
||||
if (!PARTY_TYPES.contains(type) || !types.add(type)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
|
||||
}
|
||||
if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
|
||||
Set<String> containerNos = new HashSet<>();
|
||||
for (ContainerRequest container : data.containers()) {
|
||||
String containerNo = container.containerNo().trim();
|
||||
if (!containerNos.add(containerNo)) throw badRequest("同一订舱内集装箱号不能重复");
|
||||
ContainerSpecificationCatalog.requireMatch(container.containerType(),
|
||||
container.capacityCbm(), container.maxWeightKg());
|
||||
}
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private static void validateRegisteredTransport(OceanBookingEntity booking, ReleaseRequest request) {
|
||||
@@ -252,10 +277,11 @@ public class OceanBookingService {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName, String resolvedBookingAgentName) {
|
||||
private static void copy(OceanBookingEntity v, DraftData d, String resolvedWarehouseName,
|
||||
String resolvedChannelName, String resolvedBookingAgentName) {
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
|
||||
v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName);
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolvedWarehouseName);
|
||||
v.setBillOfLadingNo(d.billOfLadingNo().trim());
|
||||
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
|
||||
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(resolvedBookingAgentName));
|
||||
@@ -268,7 +294,75 @@ public class OceanBookingService {
|
||||
}
|
||||
|
||||
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
|
||||
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
|
||||
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(1); v.setSortNo(sort[0]++); return v; }).toList(); }
|
||||
|
||||
private void createRealContainers(OceanBookingEntity booking,
|
||||
List<OceanBookingContainerRequirementEntity> requirements,
|
||||
List<ContainerRequest> requests, long operatorId,
|
||||
String operatorName) {
|
||||
for (int index = 0; index < requests.size(); index++) {
|
||||
ContainerRequest request = requests.get(index);
|
||||
OceanBookingContainerRequirementEntity requirement = requirements.get(index);
|
||||
ContainerBookingEntity container = new ContainerBookingEntity();
|
||||
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
|
||||
container.setCabinetNo("CAB-" + container.getId());
|
||||
container.setContainerNo(request.containerNo().trim());
|
||||
container.setSourceType("OCEAN_BOOKING"); container.setRecordStatus("ACTIVE");
|
||||
container.setChannelCode(booking.getChannelCode()); container.setChannelName(booking.getChannelName());
|
||||
container.setOceanBookingId(booking.getId());
|
||||
container.setOceanBookingRequirementId(requirement.getId());
|
||||
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
|
||||
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
|
||||
container.setBillOfLadingNo(booking.getBillOfLadingNo());
|
||||
container.setVesselName(booking.getVesselName()); container.setVoyageNo(booking.getVoyageNo());
|
||||
container.setContainerType(requirement.getContainerType());
|
||||
container.setShippingCompanyId(booking.getShippingCompanyId());
|
||||
container.setShippingCompanyName(booking.getShippingCompanyName());
|
||||
container.setDeparturePortCode(booking.getDeparturePortCode());
|
||||
container.setDeparturePortName(booking.getDeparturePortName());
|
||||
container.setDestinationPortCode(booking.getDestinationPortCode());
|
||||
container.setDestinationPortName(booking.getDestinationPortName());
|
||||
container.setCapacityCbm(requirement.getCapacityCbm());
|
||||
container.setMaxWeightKg(requirement.getMaxWeightKg());
|
||||
container.setEstimatedSailingAt(booking.getEstimatedSailingAt());
|
||||
container.setEstimatedPortArrivalAt(booking.getEstimatedArrivalAt());
|
||||
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
|
||||
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
|
||||
try { repository.insertContainer(container); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
throw conflict("集装箱号已存在:" + request.containerNo().trim());
|
||||
}
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
|
||||
allocation.setRequirementId(requirement.getId());
|
||||
allocation.setContainerBookingId(container.getId()); allocation.setAllocatedBy(operatorId);
|
||||
allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now());
|
||||
repository.insert(allocation);
|
||||
}
|
||||
}
|
||||
|
||||
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
|
||||
try {
|
||||
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
|
||||
internalToken, publicWarehouseTenantId, warehouseId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().warehouse() == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
|
||||
}
|
||||
WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
|
||||
if (!Long.valueOf(warehouseId).equals(warehouse.id())
|
||||
|| !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
|
||||
|| !"ACTIVE".equals(warehouse.status()) || !"DOMESTIC".equals(warehouse.warehouseType())
|
||||
|| !StringUtils.hasText(warehouse.warehouseName())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱只能选择启用中的国内中转仓");
|
||||
}
|
||||
return warehouse;
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
|
||||
}
|
||||
}
|
||||
private String hash(String action, Long id, Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action + '|' + id + '|' + objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("无法计算订舱请求摘要", ex); } }
|
||||
private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } }
|
||||
private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } }
|
||||
|
||||
@@ -17,6 +17,10 @@ public class ContainerRepository {
|
||||
}
|
||||
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
|
||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
||||
public ContainerBookingEntity mergedSourceForUpdate(long targetId) {
|
||||
return mapper.selectMergedSourceForUpdate(targetId);
|
||||
}
|
||||
public boolean containerNoExists(String no, Long excludedId) {
|
||||
if (no == null) return false;
|
||||
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
||||
@@ -29,11 +33,24 @@ public class ContainerRepository {
|
||||
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
|
||||
}
|
||||
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
|
||||
public int consumeRealSource(long sourceId, long targetId, long operatorId, String operatorName) {
|
||||
return mapper.consumeRealSource(sourceId, targetId, operatorId, operatorName);
|
||||
}
|
||||
public int mergeRealSource(long targetId, long sourceId, String containerNo, int version,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.mergeRealSource(targetId, sourceId, containerNo, version, operatorId, operatorName);
|
||||
}
|
||||
public int clearMergedLink(long targetId, int version, long operatorId, String operatorName) {
|
||||
return mapper.clearMergedLink(targetId, version, operatorId, operatorName);
|
||||
}
|
||||
public int restoreMergedSource(long sourceId, long targetId, String containerNo,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.restoreMergedSource(sourceId, targetId, containerNo, operatorId, operatorName);
|
||||
}
|
||||
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return mapper.updateDraft(value, version, operatorId, operatorName);
|
||||
}
|
||||
public int updateLink(ContainerBookingEntity value, int version, long operatorId, String operatorName) { return mapper.updateLink(value, version, operatorId, operatorName); }
|
||||
public int clearLink(long id, int version, long operatorId, String operatorName) { return mapper.clearLink(id, version, operatorId, operatorName); }
|
||||
public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; }
|
||||
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
|
||||
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
|
||||
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
|
||||
|
||||
@@ -75,7 +75,7 @@ public class ContainerOverviewService {
|
||||
if (!requestHash.equals(replay.requestHash())) throw conflict("Idempotency-Key已用于不同异常操作");
|
||||
return;
|
||||
}
|
||||
ContainerBookingEntity booking = containers.findForUpdate(id);
|
||||
ContainerBookingEntity booking = containers.findActiveForUpdate(id);
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试");
|
||||
replay = mapper.exceptionOperation(id, operatorId, operationId);
|
||||
|
||||
@@ -66,6 +66,7 @@ public class ContainerService {
|
||||
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
||||
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
|
||||
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
|
||||
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
|
||||
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
|
||||
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
|
||||
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
|
||||
@@ -78,10 +79,27 @@ public class ContainerService {
|
||||
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
||||
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
||||
OceanBookingEntity booking = oceanBookings.find(request.oceanBookingId());
|
||||
ContainerBookingEntity source = repository.findForUpdate(request.realContainerBookingId());
|
||||
if (source == null || !"OCEAN_BOOKING".equals(source.getSourceType())
|
||||
|| !"ACTIVE".equals(source.getRecordStatus()) || source.getMergedIntoContainerBookingId() != null
|
||||
|| source.getContainerNo() == null || source.getOceanBookingId() == null
|
||||
|| source.getOceanBookingRequirementId() == null) {
|
||||
throw conflict("真实订舱柜不存在、已被消费或不可关联");
|
||||
}
|
||||
if (repository.hasLoadingPlan(source.getId()) || repository.hasPickTask(source.getId())
|
||||
|| repository.hasEvidence(source.getId()) || repository.hasDocuments(source.getId())) {
|
||||
throw conflict("真实订舱柜已有装柜计划、PDA、证据或单证事实,不能被模拟柜消费");
|
||||
}
|
||||
OceanBookingEntity booking = oceanBookings.find(source.getOceanBookingId());
|
||||
if (booking == null || !("CONFIRMED".equals(booking.getStatus()) || "RELEASED".equals(booking.getStatus()))) throw conflict("只能关联已确认的真实订舱柜");
|
||||
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(booking.getId(), request.requirementId());
|
||||
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(
|
||||
booking.getId(), source.getOceanBookingRequirementId());
|
||||
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "真实订舱柜箱型分配不存在");
|
||||
var allocation = oceanBookings.allocationByContainer(source.getId());
|
||||
if (allocation == null || !java.util.Objects.equals(allocation.getOceanBookingId(), booking.getId())
|
||||
|| !java.util.Objects.equals(allocation.getRequirementId(), requirement.getId())) {
|
||||
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())
|
||||
@@ -89,17 +107,21 @@ public class ContainerService {
|
||||
|| container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null
|
||||
|| container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|
||||
|| container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
|
||||
long allocated = oceanBookings.allocations(booking.getId()).stream()
|
||||
.filter(a -> request.requirementId().equals(a.getRequirementId()) && a.getContainerBookingId() != null).count();
|
||||
if (requirement.getQuantity() == null || requirement.getQuantity() <= 0 || allocated >= requirement.getQuantity()) {
|
||||
throw conflict("真实订舱柜箱型分配已关联完毕");
|
||||
if (!java.util.Objects.equals(source.getWarehouseId(), booking.getWarehouseId())
|
||||
|| !java.util.Objects.equals(source.getChannelCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(source.getContainerType(), requirement.getContainerType())
|
||||
|| source.getCapacityCbm() == null || source.getMaxWeightKg() == null
|
||||
|| source.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|
||||
|| source.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) {
|
||||
throw conflict("真实订舱柜仓库、渠道或规格快照不一致");
|
||||
}
|
||||
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
|
||||
container.setOceanBookingId(booking.getId()); container.setOceanBookingRequirementId(requirement.getId());
|
||||
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
|
||||
if (repository.updateLink(container, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
|
||||
com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity allocation = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity();
|
||||
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId()); allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
|
||||
if (repository.consumeRealSource(source.getId(), id, operatorId, operatorName) != 1
|
||||
|| repository.mergeRealSource(id, source.getId(), source.getContainerNo(),
|
||||
request.expectedVersion(), operatorId, operatorName) != 1
|
||||
|| oceanBookings.reassignAllocation(source.getId(), id) != 1) {
|
||||
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
|
||||
}
|
||||
return required(id);
|
||||
}
|
||||
|
||||
@@ -107,8 +129,15 @@ public class ContainerService {
|
||||
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
|
||||
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
|
||||
oceanBookings.deleteAllocation(id);
|
||||
if (repository.clearLink(id, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); return required(id);
|
||||
ContainerBookingEntity source = repository.mergedSourceForUpdate(id);
|
||||
if (source == null) throw conflict("真实订舱柜合并来源不存在,不能解除关联");
|
||||
String containerNo = container.getContainerNo();
|
||||
if (repository.clearMergedLink(id, request.expectedVersion(), operatorId, operatorName) != 1
|
||||
|| repository.restoreMergedSource(source.getId(), id, containerNo, operatorId, operatorName) != 1
|
||||
|| oceanBookings.reassignAllocation(id, source.getId()) != 1) {
|
||||
throw conflict("货柜状态或版本已变化,请刷新后重试");
|
||||
}
|
||||
return required(id);
|
||||
}
|
||||
|
||||
private void requireFirstLinkMutable(ContainerBookingEntity container) {
|
||||
|
||||
@@ -292,7 +292,7 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
|
||||
END IF;
|
||||
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
|
||||
('CONFIRMED','CHANGE_PENDING') THEN
|
||||
('CONFIRMED','RELEASED') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -31,6 +31,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
|
||||
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已合并历史',
|
||||
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
|
||||
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
|
||||
@@ -72,14 +75,59 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_container_booking_no (booking_no),
|
||||
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
|
||||
KEY idx_container_booking_container (container_no),
|
||||
UNIQUE KEY uk_container_booking_container_no (container_no),
|
||||
KEY idx_container_booking_merge (record_status,merged_into_container_booking_id),
|
||||
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
|
||||
KEY idx_container_booking_voyage (voyage_no, updated_at),
|
||||
KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type),
|
||||
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)
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_container_real_unit_columns()
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='source_type') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'
|
||||
COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜' AFTER cabinet_no;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='record_status') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'
|
||||
COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史' AFTER source_type;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='merged_into_container_booking_id') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
|
||||
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
|
||||
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='idx_container_booking_merge') THEN
|
||||
ALTER TABLE fl_container_booking ADD KEY idx_container_booking_merge (record_status,merged_into_container_booking_id);
|
||||
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_source') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_source
|
||||
CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'));
|
||||
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_record_status') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
|
||||
CHECK (record_status IN ('ACTIVE','MERGED'));
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_container_real_unit_columns();
|
||||
DROP PROCEDURE phase3_container_real_unit_columns;
|
||||
|
||||
-- 触发器创建前先收敛旧库货柜供应商ID列;历史名称无法反推ID,保持NULL。
|
||||
DROP PROCEDURE IF EXISTS phase3_container_supplier_snapshot_add_column;
|
||||
DELIMITER $$
|
||||
|
||||
@@ -250,11 +250,11 @@ paths:
|
||||
'403': {description: 无新增按钮权限}
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到真实已确认订舱柜
|
||||
description: 需要 platform:firstleg:container:edit;首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items;存在 PDA、有效装柜证据、业务单证或已进入 LOADING/COMPLETED 等不可逆事实时拒绝。
|
||||
summary: 将内部模拟柜原地合并为指定真实订舱柜
|
||||
description: 需要 platform:firstleg:container:edit;请求指定未消费且尚无装柜计划/PDA/证据/单证的 realContainerBookingId。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,将真实柜 containerNo、订舱、运输和范围快照原子复制到模拟柜并把 allocation 改指模拟柜;原真实柜置 MERGED 且不再出现在正常货柜列表。存在跨仓、跨渠道、柜型规格不一致、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一 containerBookingId/cabinetNo}}
|
||||
responses: {'200': {description: 合并后的同一模拟柜 containerBookingId/cabinetNo;列表仅保留这一条 ACTIVE 操作身份}}
|
||||
delete:
|
||||
summary: 解除内部模拟柜的真实订舱关联
|
||||
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
|
||||
@@ -1212,13 +1212,16 @@ components:
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
ContainerBooking:
|
||||
type: object
|
||||
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, version]
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, version]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
bookingNo: {type: string, maxLength: 40}
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
cabinetNo: {type: string, maxLength: 64}
|
||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
|
||||
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
|
||||
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
|
||||
channelName: {type: string, maxLength: 128, nullable: true}
|
||||
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
@@ -1456,10 +1459,9 @@ components:
|
||||
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
|
||||
ContainerOceanBookingLinkRequest:
|
||||
type: object
|
||||
required: [oceanBookingId, requirementId, expectedVersion]
|
||||
required: [realContainerBookingId, expectedVersion]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1}
|
||||
requirementId: {type: integer, format: int64, minimum: 1}
|
||||
realContainerBookingId: {type: integer, format: int64, minimum: 1, description: 订舱创建的 OCEAN_BOOKING/ACTIVE 真实柜ID;成功合并后该来源记录转为 MERGED}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
ContainerOceanBookingUnlinkRequest:
|
||||
type: object
|
||||
|
||||
@@ -21,10 +21,10 @@ paths:
|
||||
responses: {'200': {description: Operational real booking page}}
|
||||
post:
|
||||
summary: Register an offline-confirmed ocean booking with complete transport snapshots
|
||||
description: 'Requires platform:firstleg:ocean-booking:create. The booking has already been completed offline, so registration persists it directly as CONFIRMED. RELEASED remains a separate later action.'
|
||||
description: 'Requires platform:firstleg:ocean-booking:create. Registers every real container unit atomically, creates one stable fl_container_booking plus one requirement/allocation per container, and persists the booking directly as CONFIRMED. RELEASED remains a separate later action.'
|
||||
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}}
|
||||
responses: {'200': {description: CONFIRMED real booking detail}}
|
||||
responses: {'200': {description: CONFIRMED real booking detail, content: {application/json: {schema: {$ref: '#/components/schemas/Detail'}}}}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Release an already registered booking and synchronize associated cabinet transport snapshots
|
||||
@@ -32,6 +32,33 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}:
|
||||
get:
|
||||
summary: Get an ocean booking with its real container units
|
||||
description: Requires platform:firstleg:ocean-booking:list.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses: {'200': {description: Booking detail, content: {application/json: {schema: {$ref: '#/components/schemas/Detail'}}}}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/cancel:
|
||||
post:
|
||||
summary: Cancel a confirmed or change-pending real booking
|
||||
description: Requires platform:firstleg:ocean-booking:cancel. A non-blank reason is required; RELEASED bookings are irreversible.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: CANCELLED detail}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/change-request:
|
||||
post:
|
||||
summary: Request a voyage change for a confirmed booking
|
||||
description: Requires platform:firstleg:ocean-booking:change. Current transport snapshots remain active until change-confirm succeeds.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ChangeRequest'}}}}
|
||||
responses: {'200': {description: CHANGE_PENDING detail}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/change-confirm:
|
||||
post:
|
||||
summary: Confirm a pending voyage change
|
||||
description: Requires platform:firstleg:ocean-booking:change.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: CONFIRMED detail with the pending voyage snapshots applied}}
|
||||
components:
|
||||
parameters:
|
||||
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
@@ -45,13 +72,14 @@ components:
|
||||
data: {$ref: '#/components/schemas/DraftData'}
|
||||
DraftData:
|
||||
type: object
|
||||
required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]
|
||||
required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]
|
||||
properties:
|
||||
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.}
|
||||
customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.}
|
||||
channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
warehouseName: {type: string, maxLength: 128}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1, description: ACTIVE domestic transit warehouse ID; warehouseName is resolved and frozen server-side.}
|
||||
shippingCompanyId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional shipping-company master-data ID; the carrier is independent from the logistics channel and booking-agent Supplier.}
|
||||
shippingCompanyName: {type: string, maxLength: 128, nullable: true, description: Optional shipping-company name snapshot.}
|
||||
bookingAgentId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as BOOKING with INTERNATIONAL scope.}
|
||||
bookingAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot always comes from Warehouse and cannot be supplied without bookingAgentId.}
|
||||
serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.}
|
||||
@@ -59,19 +87,62 @@ components:
|
||||
billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed bill of lading snapshot.}
|
||||
vesselName: {type: string, maxLength: 128, description: Required offline-confirmed vessel snapshot.}
|
||||
voyageNo: {type: string, maxLength: 64, description: Required offline-confirmed voyage snapshot.}
|
||||
departurePortCode: {type: string, maxLength: 32, nullable: true}
|
||||
departurePortName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationPortCode: {type: string, maxLength: 32, nullable: true}
|
||||
destinationPortName: {type: string, maxLength: 128, nullable: true}
|
||||
placeOfDelivery: {type: string, maxLength: 128, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, description: Required offline-confirmed ETD snapshot.}
|
||||
estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA snapshot; cannot be earlier than ETD.}
|
||||
parties: {type: array, minItems: 3, items: {type: object}}
|
||||
containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}}
|
||||
ContainerRequirement:
|
||||
remark: {type: string, maxLength: 1000, nullable: true}
|
||||
parties: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/PartyRequest'}}
|
||||
containers: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequest'}}
|
||||
PartyRequest:
|
||||
type: object
|
||||
required: [containerType, containerKind, capacityCbm, maxWeightKg, quantity]
|
||||
required: [partyType, partyName]
|
||||
properties:
|
||||
partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}
|
||||
partyName: {type: string, maxLength: 255}
|
||||
address: {type: string, maxLength: 1000, nullable: true}
|
||||
contactName: {type: string, maxLength: 128, nullable: true}
|
||||
contactPhone: {type: string, maxLength: 64, nullable: true}
|
||||
ContainerRequest:
|
||||
type: object
|
||||
required: [containerNo, containerType, containerKind, capacityCbm, maxWeightKg]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
containerNo: {type: string, minLength: 1, maxLength: 64, description: Required real container number; unique across all ACTIVE and unmerged container records.}
|
||||
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: Platform-controlled container type}
|
||||
containerKind: {type: string, maxLength: 32}
|
||||
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
|
||||
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
|
||||
quantity: {type: integer, minimum: 1}
|
||||
Detail:
|
||||
type: object
|
||||
required: [oceanBooking, parties, containers, histories]
|
||||
properties:
|
||||
oceanBooking: {type: object, description: Ocean booking aggregate and frozen transport snapshots.}
|
||||
parties: {type: array, items: {type: object}}
|
||||
containers: {type: array, items: {$ref: '#/components/schemas/RealContainer'}}
|
||||
histories: {type: array, items: {type: object}}
|
||||
RealContainer:
|
||||
type: object
|
||||
required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId, containerType, containerKind, capacityCbm, maxWeightKg, warehouseId, warehouseName, channelCode, channelName]
|
||||
properties:
|
||||
containerBookingId: {type: integer, format: int64, minimum: 1, description: Current ACTIVE operational container identity.}
|
||||
cabinetNo: {type: string, maxLength: 64, description: Stable server-generated internal cabinet number.}
|
||||
containerNo: {type: string, maxLength: 64, description: Unique real container number.}
|
||||
requirementId: {type: integer, format: int64, minimum: 1}
|
||||
allocationId: {type: integer, format: int64, minimum: 1}
|
||||
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC]}
|
||||
containerKind: {type: string, maxLength: 32}
|
||||
capacityCbm: {type: number}
|
||||
maxWeightKg: {type: number}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
warehouseName: {type: string, maxLength: 128}
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
channelName: {type: string, maxLength: 128}
|
||||
customerId: {type: integer, format: int64, minimum: 1, nullable: true}
|
||||
customerName: {type: string, maxLength: 128, nullable: true}
|
||||
ChannelOption:
|
||||
type: object
|
||||
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
|
||||
@@ -84,7 +155,17 @@ components:
|
||||
ActionRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000}}
|
||||
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000, nullable: true}}
|
||||
ChangeRequest:
|
||||
type: object
|
||||
required: [expectedVersion, vesselName, voyageNo, reason]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
vesselName: {type: string, maxLength: 128}
|
||||
voyageNo: {type: string, maxLength: 64}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
reason: {type: string, maxLength: 1000}
|
||||
ReleaseRequest:
|
||||
type: object
|
||||
required: [expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt]
|
||||
|
||||
+8
-1
@@ -59,9 +59,11 @@ class ContainerControllerContractTest {
|
||||
assertThat(section)
|
||||
.contains("/api/platform/v1/containers/{id}/transport:")
|
||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("尚无装柜计划/PDA/证据/单证的 realContainerBookingId")
|
||||
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
|
||||
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
|
||||
.contains("realContainerBookingId")
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||
.contains("multipart/form-data")
|
||||
.contains("ContainerDocumentUploadRequest")
|
||||
@@ -72,6 +74,11 @@ class ContainerControllerContractTest {
|
||||
.contains("COMPLETED")
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
|
||||
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
|
||||
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
|
||||
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+10
-2
@@ -51,8 +51,16 @@ class PlatformOceanBookingControllerContractTest {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
|
||||
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
|
||||
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
|
||||
.contains("required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]")
|
||||
.contains("registration persists it directly as CONFIRMED")
|
||||
.contains("required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]")
|
||||
.contains("containerNo: {type: string, maxLength: 64")
|
||||
.contains("containers: {type: array, minItems: 1, maxItems: 20")
|
||||
.contains("required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId")
|
||||
.contains("warehouseName is resolved and frozen server-side")
|
||||
.contains("shippingCompanyId:", "departurePortCode:", "destinationPortCode:",
|
||||
"placeOfDelivery:", "remark:", "PartyRequest:")
|
||||
.contains("/{oceanBookingId}/cancel:", "/{oceanBookingId}/change-request:",
|
||||
"/{oceanBookingId}/change-confirm:")
|
||||
.contains("persists the booking directly as CONFIRMED")
|
||||
.contains("RELEASED remains a separate later action")
|
||||
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
|
||||
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
|
||||
|
||||
+2
@@ -38,6 +38,8 @@ class OceanBookingSqlContractTest {
|
||||
.contains("OLD.status IN ('RELEASED','CANCELLED')")
|
||||
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
|
||||
.contains("request_hash CHAR(64) NOT NULL")
|
||||
.contains("('CONFIRMED','RELEASED') THEN")
|
||||
.doesNotContain("('CONFIRMED','CHANGE_PENDING') THEN")
|
||||
.contains("订舱状态历史不可修改")
|
||||
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
|
||||
}
|
||||
|
||||
+101
-10
@@ -1,9 +1,11 @@
|
||||
package com.baiyue.firstleg.oceanbooking.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequirementRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
|
||||
@@ -12,6 +14,7 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
@@ -44,6 +47,7 @@ class OceanBookingServiceTest {
|
||||
@Mock private PriceBookQueryRepository priceBooks;
|
||||
@Mock private LoadingPickService loadingPickService;
|
||||
@Mock private WarehouseSupplierSnapshotService suppliers;
|
||||
@Mock private WarehouseMasterDataClient warehouses;
|
||||
@InjectMocks private OceanBookingService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -52,10 +56,13 @@ class OceanBookingServiceTest {
|
||||
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
|
||||
when(repository.parties(anyLong())).thenReturn(List.of());
|
||||
when(repository.requirements(anyLong())).thenReturn(List.of());
|
||||
when(repository.containers(anyLong())).thenReturn(List.of());
|
||||
when(repository.allocations(anyLong())).thenReturn(List.of());
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
when(suppliers.booking(any(), any())).thenReturn(null);
|
||||
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
|
||||
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
|
||||
701L, 0L, "SZX", "Warehouse深圳中转仓", "DOMESTIC", "ACTIVE"))));
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
|
||||
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
|
||||
@@ -179,6 +186,7 @@ class OceanBookingServiceTest {
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
|
||||
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
|
||||
assertThat(result.oceanBooking().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
|
||||
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
|
||||
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
|
||||
@@ -194,6 +202,80 @@ class OceanBookingServiceTest {
|
||||
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
|
||||
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
|
||||
"admin");
|
||||
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository).insertContainer(containers.capture());
|
||||
assertThat(containers.getValue().getContainerNo()).isEqualTo("MSCU-001");
|
||||
assertThat(containers.getValue().getSourceType()).isEqualTo("OCEAN_BOOKING");
|
||||
assertThat(containers.getValue().getRecordStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(containers.getValue().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
|
||||
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
|
||||
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
|
||||
verify(repository).insert(allocation.capture());
|
||||
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(containers.getValue().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneRequirementContainerAndAllocationPerRealContainerUnit() {
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
|
||||
stubSuccessfulCreation();
|
||||
DraftData source = draft();
|
||||
List<ContainerRequest> requested = List.of(
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
|
||||
new ContainerRequest("MSCU-002", "20GP", "DRY", new BigDecimal("33"), new BigDecimal("28000")));
|
||||
|
||||
service.create(9L, "admin", "create-two-containers",
|
||||
new CreateRequest(0, withContainers(source, requested)));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> requirements =
|
||||
ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceRequirements(anyLong(), requirements.capture());
|
||||
assertThat(requirements.getValue()).hasSize(2)
|
||||
.allSatisfy(requirement -> assertThat(requirement.getQuantity()).isEqualTo(1));
|
||||
assertThat(requirements.getValue()).extracting(OceanBookingContainerRequirementEntity::getContainerType)
|
||||
.containsExactly("40HC", "20GP");
|
||||
|
||||
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository, times(2)).insertContainer(containers.capture());
|
||||
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getContainerNo)
|
||||
.containsExactly("MSCU-001", "MSCU-002");
|
||||
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getId).doesNotHaveDuplicates();
|
||||
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getCabinetNo).doesNotHaveDuplicates();
|
||||
|
||||
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
|
||||
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
|
||||
verify(repository, times(2)).insert(allocations.capture());
|
||||
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
|
||||
.containsExactlyElementsOf(containers.getAllValues().stream().map(ContainerBookingEntity::getId).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateContainerNumbersWithinOneRegistrationBeforeWriting() {
|
||||
DraftData source = draft();
|
||||
List<ContainerRequest> duplicated = List.of(
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-in-request",
|
||||
new CreateRequest(0, withContainers(source, duplicated))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能重复");
|
||||
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerNumberAlreadyRegisteredBeforeCreatingAggregate() {
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
|
||||
when(repository.containerNoExists("MSCU-001")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-existing",
|
||||
new CreateRequest(0, draft())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号已存在");
|
||||
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -240,12 +322,12 @@ class OceanBookingServiceTest {
|
||||
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
|
||||
DraftData source = draft();
|
||||
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
|
||||
source.warehouseId(), source.warehouseName(), source.billOfLadingNo(), source.shippingCompanyId(),
|
||||
source.warehouseId(), source.billOfLadingNo(), source.shippingCompanyId(),
|
||||
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
|
||||
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
|
||||
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
|
||||
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
|
||||
source.remark(), source.parties(), source.containerRequirements());
|
||||
source.remark(), source.parties(), source.containers());
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
|
||||
@@ -257,12 +339,12 @@ class OceanBookingServiceTest {
|
||||
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
|
||||
DraftData source = draft();
|
||||
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
|
||||
source.warehouseId(), source.warehouseName(), null, source.shippingCompanyId(),
|
||||
source.warehouseId(), null, source.shippingCompanyId(),
|
||||
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
|
||||
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
|
||||
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
|
||||
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
|
||||
source.remark(), source.parties(), source.containerRequirements());
|
||||
source.remark(), source.parties(), source.containers());
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
|
||||
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
|
||||
@@ -308,12 +390,21 @@ class OceanBookingServiceTest {
|
||||
new PartyRequest("SHIPPER", "发货人", null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
|
||||
List<ContainerRequirementRequest> requirements = List.of(
|
||||
new ContainerRequirementRequest("40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000"), 1));
|
||||
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", "BL-001",
|
||||
List<ContainerRequest> containers = List.of(
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
|
||||
return new DraftData(customerId, customerName, "SEA-US", 701L, "BL-001",
|
||||
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, requirements);
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, containers);
|
||||
}
|
||||
|
||||
private static DraftData withContainers(DraftData source, List<ContainerRequest> containers) {
|
||||
return new DraftData(source.customerId(), source.customerName(), source.channelCode(), source.warehouseId(),
|
||||
source.billOfLadingNo(), source.shippingCompanyId(), source.shippingCompanyName(),
|
||||
source.bookingAgentId(), source.bookingAgentName(), source.vesselName(), source.voyageNo(),
|
||||
source.departurePortCode(), source.departurePortName(), source.destinationPortCode(),
|
||||
source.destinationPortName(), source.placeOfDelivery(), source.serviceTerm(), source.freightTerm(),
|
||||
source.estimatedSailingAt(), source.estimatedArrivalAt(), source.remark(), source.parties(), containers);
|
||||
}
|
||||
|
||||
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ class LoadingPickPdaTaskSqlContractTest {
|
||||
|
||||
assertThat(sql)
|
||||
.contains("task.task_status = 'LOADING'")
|
||||
.contains("booking.record_status = 'ACTIVE'")
|
||||
.contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}")
|
||||
.contains("booking.warehouse_id AS warehouseId")
|
||||
.doesNotContain("tenantId");
|
||||
|
||||
+6
-3
@@ -14,8 +14,11 @@ class LoadingPickStableLookupContractTest {
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}").doesNotContain("task.container_no");
|
||||
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}").doesNotContain("task.container_no");
|
||||
assertThat(legacy).contains("booking.container_no = #{containerNo}").doesNotContain("task.container_no =");
|
||||
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'")
|
||||
.doesNotContain("task.container_no");
|
||||
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'")
|
||||
.doesNotContain("task.container_no");
|
||||
assertThat(legacy).contains("booking.container_no = #{containerNo}",
|
||||
"booking.record_status = 'ACTIVE'").doesNotContain("task.container_no =");
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -61,6 +61,8 @@ class SmartLoadingSqlContractTest {
|
||||
@Test
|
||||
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
|
||||
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
|
||||
assertContainerLifecycleSchema(migration());
|
||||
assertContainerLifecycleSchema(unifiedMigration());
|
||||
assertThat(migration())
|
||||
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
|
||||
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
|
||||
@@ -73,6 +75,20 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
||||
}
|
||||
|
||||
private static void assertContainerLifecycleSchema(String sql) {
|
||||
assertThat(sql)
|
||||
.contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'")
|
||||
.contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
|
||||
.contains("merged_into_container_booking_id BIGINT UNSIGNED NULL")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
|
||||
.contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))")
|
||||
.contains("CHECK (record_status IN ('ACTIVE','MERGED'))")
|
||||
.contains("column_name='source_type'")
|
||||
.contains("column_name='record_status'")
|
||||
.contains("column_name='merged_into_container_booking_id'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
|
||||
void shouldProvideSimpleCustomsDocuments() throws Exception {
|
||||
|
||||
+2
-2
@@ -112,11 +112,11 @@ class ContainerOverviewServiceTest {
|
||||
|
||||
@Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
|
||||
when(containers.findForUpdate(41L)).thenReturn(booking);
|
||||
when(containers.findActiveForUpdate(41L)).thenReturn(booking);
|
||||
|
||||
service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal"));
|
||||
|
||||
verify(containers).findForUpdate(41L);
|
||||
verify(containers).findActiveForUpdate(41L);
|
||||
verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L),
|
||||
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -285,23 +286,90 @@ class ContainerServiceTest {
|
||||
ContainerBookingEntity container = booking("EDITING", 2);
|
||||
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
|
||||
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(realContainer);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocations(21L)).thenReturn(java.util.List.of());
|
||||
when(repository.updateLink(any(), org.mockito.ArgumentMatchers.eq(2), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
|
||||
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
|
||||
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
|
||||
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
|
||||
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2));
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
|
||||
|
||||
assertThat(result).isSameAs(container);
|
||||
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
|
||||
assertThat(container.getOceanBookingId()).isEqualTo(21L);
|
||||
verify(repository, never()).hasActiveLoadingItems(11L);
|
||||
verify(oceanBookings).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
|
||||
verify(repository).consumeRealSource(41L, 11L, 9L, "admin");
|
||||
verify(repository).mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin");
|
||||
verify(oceanBookings).reassignAllocation(41L, 11L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联允许消费已放舱的真实订舱柜")
|
||||
void linksReleasedBooking() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
|
||||
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setContainerType("40HC");
|
||||
requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(realContainer);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
|
||||
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
|
||||
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
|
||||
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
|
||||
|
||||
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
|
||||
|
||||
verify(oceanBookings).reassignAllocation(41L, 11L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已合并的真实订舱柜不可再次关联")
|
||||
void rejectsConsumedRealContainer() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
ContainerBookingEntity consumed = realContainer(41L, 21L, 31L);
|
||||
consumed.setRecordStatus("MERGED"); consumed.setMergedIntoContainerBookingId(99L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(consumed);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(41L, 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已被消费");
|
||||
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已有装柜计划的真实订舱柜不可被模拟柜消费")
|
||||
void rejectsRealContainerWithLoadingPlan() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(realContainer);
|
||||
when(repository.hasLoadingPlan(41L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(41L, 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已有装柜计划");
|
||||
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -311,11 +379,21 @@ class ContainerServiceTest {
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.hasEvidence(11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2)))
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("证据");
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity realContainer(long id, long oceanBookingId, long requirementId) {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(id); value.setContainerNo("MSCU7654321"); value.setCabinetNo("CAB-REAL");
|
||||
value.setSourceType("OCEAN_BOOKING"); value.setRecordStatus("ACTIVE");
|
||||
value.setOceanBookingId(oceanBookingId); value.setOceanBookingRequirementId(requirementId);
|
||||
value.setWarehouseId(101L); value.setChannelCode("SEA-US"); value.setContainerType("40HC");
|
||||
value.setCapacityCbm(new BigDecimal("76.0000")); value.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
|
||||
String type,
|
||||
String name) {
|
||||
|
||||
Reference in New Issue
Block a user