This commit is contained in:
2026-08-17 13:24:48 +08:00
parent ac6cf9c904
commit 6739c83467
43 changed files with 1049 additions and 315 deletions
@@ -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,
@@ -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);
}
@@ -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; }
@@ -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]
@@ -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
@@ -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")
@@ -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)");
}
@@ -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() {
@@ -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");
@@ -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 =");
}
}
@@ -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 {
@@ -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) {