feat(firstleg): link simulated containers to real bookings

This commit is contained in:
2026-08-14 20:59:03 +08:00
parent 993b3dfe03
commit 7184f5083a
21 changed files with 296 additions and 184 deletions
@@ -4768,6 +4768,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID', ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围',
customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -6,8 +6,10 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService; import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -88,9 +90,15 @@ public class PlatformContainerController {
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false) @BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody SaveRequest request) { @Valid @RequestBody SimulationCreateRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request)); FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createSimulation(user.userId(), user.username(), request));
} }
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜")
public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许") @Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
@@ -92,4 +92,18 @@ public final class ContainerDtos {
this(containerNo, voyageNo, version, "运输信息更新"); this(containerNo, voyageNo, version, "运输信息更新");
} }
} }
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
@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 = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 500) String reason) {}
} }
@@ -24,6 +24,8 @@ public class ContainerBookingEntity {
private String channelName; private String channelName;
private Long oceanBookingId; private Long oceanBookingId;
private Long oceanBookingRequirementId; private Long oceanBookingRequirementId;
private Long customerId;
private String customerName;
private Long warehouseId; private Long warehouseId;
private String warehouseName; private String warehouseName;
private String billOfLadingNo; private String billOfLadingNo;
@@ -24,7 +24,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo}) WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo}) 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.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
AND (#{query.customerId} IS NULL OR EXISTS ( AND (#{query.customerId} IS NULL OR b.customer_id = #{query.customerId} OR EXISTS (
SELECT 1 FROM fl_ocean_booking_container_allocation customer_allocation SELECT 1 FROM fl_ocean_booking_container_allocation customer_allocation
JOIN fl_ocean_booking customer_booking ON customer_booking.id = customer_allocation.ocean_booking_id JOIN fl_ocean_booking customer_booking ON customer_booking.id = customer_allocation.ocean_booking_id
WHERE customer_allocation.container_booking_id = b.id WHERE customer_allocation.container_booking_id = b.id
@@ -98,6 +98,15 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
""") """)
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion, int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @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_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);
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@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 AND i.account_id&lt;&gt;#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId);
@Update(""" @Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
@@ -65,8 +65,5 @@ public class PlatformOceanBookingController {
@PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')") @PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败") @BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败")
public ApiResponse<Detail> failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); } public ApiResponse<Detail> failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')")
@BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次")
public ApiResponse<Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); }
private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); } private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); }
} }
@@ -83,9 +83,6 @@ public final class OceanBookingDtos {
LocalDateTime estimatedSailingAt, LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt, LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 1000) String reason) {} @NotBlank @Size(max = 1000) String reason) {}
public record AllocationRequest(@NotNull @Positive Long requirementId,
@NotNull @Positive Long containerBookingId,
@NotNull @Min(0) Integer expectedVersion) {}
public record Detail(OceanBookingEntity oceanBooking, public record Detail(OceanBookingEntity oceanBooking,
List<OceanBookingPartyEntity> parties, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> containerRequirements, List<OceanBookingContainerRequirementEntity> containerRequirements,
@@ -17,6 +17,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
<script> <script>
SELECT * FROM fl_ocean_booking SELECT * FROM fl_ocean_booking
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId}) WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND status IN ('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED')
AND (#{query.status} IS NULL OR status=#{query.status}) AND (#{query.status} IS NULL OR status=#{query.status})
AND (#{query.keyword} IS NULL OR booking_no LIKE CONCAT('%',#{query.keyword},'%') AND (#{query.keyword} IS NULL OR booking_no LIKE CONCAT('%',#{query.keyword},'%')
OR customer_name LIKE CONCAT('%',#{query.keyword},'%') OR customer_name LIKE CONCAT('%',#{query.keyword},'%')
@@ -95,13 +96,4 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion, int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET version=version+1,updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{oceanBookingId} AND version=#{expectedVersion}
AND status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')
""")
int touchForAllocation(@Param("oceanBookingId") long oceanBookingId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
} }
@@ -3,10 +3,6 @@ package com.baiyue.firstleg.oceanbooking.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.entity.*;
@@ -24,8 +20,6 @@ public class OceanBookingRepository {
@Resource private OceanBookingContainerAllocationMapper allocations; @Resource private OceanBookingContainerAllocationMapper allocations;
@Resource private OceanBookingStatusHistoryMapper histories; @Resource private OceanBookingStatusHistoryMapper histories;
@Resource private OceanBookingOperationMapper operations; @Resource private OceanBookingOperationMapper operations;
@Resource private ContainerBookingMapper containerBookings;
@Resource private LoadingPlanMapper loadingPlans;
public PageResult<OceanBookingEntity> page(Query query) { public PageResult<OceanBookingEntity> page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
@@ -53,9 +47,6 @@ public class OceanBookingRepository {
public int confirmChange(long id, int version, long operatorId, String operatorName) { public int confirmChange(long id, int version, long operatorId, String operatorName) {
return bookings.confirmChange(id, version, operatorId, operatorName); return bookings.confirmChange(id, version, operatorId, operatorName);
} }
public int touchForAllocation(long id, int version, long operatorId, String operatorName) {
return bookings.touchForAllocation(id, version, operatorId, operatorName);
}
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } 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<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<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
@@ -67,14 +58,12 @@ public class OceanBookingRepository {
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); } public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); } public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); } public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); }
public void insert(ContainerBookingEntity value) { containerBookings.insert(value); }
public void insert(LoadingPlanEntity value) { loadingPlans.insert(value); }
public int syncReleasedTransport(long id, ReleaseRequest request, long operatorId, String operatorName) { public int syncReleasedTransport(long id, ReleaseRequest request, long operatorId, String operatorName) {
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(), return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName); request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
} }
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); } public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
public void deleteAllocation(long containerBookingId) { allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); }
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); } public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; } public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper<OceanBookingOperationEntity>().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); } public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper<OceanBookingOperationEntity>().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); }
@@ -4,8 +4,6 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.entity.*;
@@ -17,7 +15,6 @@ import com.baiyue.firstleg.service.LoadingPickService;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -34,6 +31,9 @@ import java.util.Set;
@Service @Service
public class OceanBookingService { public class OceanBookingService {
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
private static final Set<OceanBookingStatus> OPERATIONAL_LIST_STATUSES = Set.of(
OceanBookingStatus.CONFIRMED, OceanBookingStatus.CHANGE_PENDING,
OceanBookingStatus.RELEASED, OceanBookingStatus.CANCELLED);
@Resource private OceanBookingRepository repository; @Resource private OceanBookingRepository repository;
@Resource private PriceBookQueryRepository priceBooks; @Resource private PriceBookQueryRepository priceBooks;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -41,7 +41,9 @@ public class OceanBookingService {
public PageResult<OceanBookingEntity> page(Query query) { public PageResult<OceanBookingEntity> page(Query query) {
String status = trim(query.status()); String status = trim(query.status());
if (status != null) parseStatus(status); if (status != null && !OPERATIONAL_LIST_STATUSES.contains(parseStatus(status))) {
throw badRequest("订舱列表仅支持已确认、变更中、已放舱或已取消状态");
}
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
} }
@@ -116,7 +118,6 @@ public class OceanBookingService {
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id); OceanBookingEntity after = required(id);
generateContainers(after, repository.requirements(id), operatorId, name);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name); operatorId, name);
return complete(id, operatorId, action, key, detail(after)); return complete(id, operatorId, action, key, detail(after));
@@ -210,33 +211,6 @@ public class OceanBookingService {
return complete(id, operatorId, action, key, detail(after)); return complete(id, operatorId, action, key, detail(after));
} }
@Transactional
public Detail allocate(long id, long operatorId, String name, String key, AllocationRequest request) {
String action = "ALLOCATE_CONTAINER";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity booking = requiredVersion(id, request.expectedVersion());
if (!Set.of(OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
OceanBookingStatus.CHANGE_PENDING.name()).contains(booking.getStatus())) {
throw conflict("当前订舱状态不可分配柜次");
}
OceanBookingContainerRequirementEntity requirement = repository.requirement(id, request.requirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "箱型需求不存在");
ContainerBookingEntity container = repository.containerBooking(request.containerBookingId());
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
if (!requirement.getContainerType().equals(container.getContainerType())) throw conflict("柜次箱型与订舱需求不一致");
requireSameChannel(booking, container);
long used = repository.allocations(id).stream().filter(v -> request.requirementId().equals(v.getRequirementId())).count();
if (used >= requirement.getQuantity()) throw conflict("该箱型需求已分配完毕");
if (repository.touchForAllocation(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(id);
allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(request.containerBookingId());
allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(name); allocation.setAllocatedAt(LocalDateTime.now());
try { repository.insert(allocation); } catch (DuplicateKeyException ex) { throw conflict("柜次已被其他订舱占用"); }
return complete(id, operatorId, action, key, detail(id));
}
private Detail transition(long id, long operatorId, String name, String key, String action, private Detail transition(long id, long operatorId, String name, String key, String action,
OceanBookingStatus from, OceanBookingStatus to, ActionRequest request, OceanBookingStatus from, OceanBookingStatus to, ActionRequest request,
boolean failure) { boolean failure) {
@@ -348,44 +322,6 @@ public class OceanBookingService {
v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark())); v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark()));
} }
private void generateContainers(OceanBookingEntity booking,
List<OceanBookingContainerRequirementEntity> requirements,
long operatorId, String operatorName) {
for (OceanBookingContainerRequirementEntity requirement : requirements) {
for (int sequence = 1; sequence <= requirement.getQuantity(); sequence++) {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + booking.getBookingNo() + '-' + requirement.getSortNo() + '-' + sequence);
container.setContainerNo(null); container.setChannelCode(booking.getChannelCode());
container.setChannelName(booking.getChannelName()); container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setContainerType(requirement.getContainerType());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
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.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
repository.insert(container);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + container.getId()); plan.setContainerBookingId(container.getId());
plan.setPlanStatus("EDITING"); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); plan.setVersion(0);
repository.insert(plan);
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 static void validateConfirmation(OceanBookingEntity booking, private static void validateConfirmation(OceanBookingEntity booking,
List<OceanBookingContainerRequirementEntity> requirements) { List<OceanBookingContainerRequirementEntity> requirements) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName()) if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName())
@@ -398,13 +334,6 @@ public class OceanBookingService {
} }
} }
private static void requireSameChannel(OceanBookingEntity booking, ContainerBookingEntity container) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(container.getChannelCode())) {
throw conflict("订舱或柜次缺少渠道快照,请先完成存量数据回填");
}
if (!booking.getChannelCode().equals(container.getChannelCode())) throw conflict("柜次渠道与订舱渠道不一致");
}
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<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 -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); } private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
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 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); } }
@@ -32,6 +32,13 @@ public class ContainerRepository {
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) { public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, 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 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; }
public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; }
public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; }
public int updateTransport(long id, String containerNo, String voyageNo, int version, public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) { long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
@@ -11,6 +11,9 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
@@ -18,6 +21,11 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
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.OceanBookingContainerRequirementEntity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -34,6 +42,8 @@ public class ContainerService {
@Resource private SmartLoadingRepository smartLoadingRepository; @Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickRepository loadingPickRepository;
@Resource private LoadingPickService loadingPickService; @Resource private LoadingPickService loadingPickService;
@Resource private PriceBookQueryRepository priceBooks;
@Resource private OceanBookingRepository oceanBookings;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories; @Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -44,20 +54,70 @@ public class ContainerService {
public ContainerBookingEntity detail(long id) { return required(id); } public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional @Transactional
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
validateSchedule(request);
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null); Long versionId = priceBooks.activeVersionId();
ContainerBookingEntity value = new ContainerBookingEntity(); if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
value.setId(IdWorker.getId()); ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream()
value.setBookingNo("CB" + value.getId()); .filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
copy(value, request); .orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName()); value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setContainerType(request.containerType().trim()); value.setCapacityCbm(request.capacityCbm());
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); value.setMaxWeightKg(request.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value);
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); return value;
return value; }
@Transactional
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());
if (booking == null || !("CONFIRMED".equals(booking.getStatus()) || "RELEASED".equals(booking.getStatus()))) throw conflict("只能关联已确认的真实订舱柜");
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(booking.getId(), request.requirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "真实订舱柜箱型分配不存在");
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())
|| container.getCapacityCbm() == null || requirement.getCapacityCbm() == null
|| 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 (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);
return required(id);
}
@Transactional
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
oceanBookings.deleteAllocation(id);
if (repository.clearLink(id, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); return required(id);
}
private void requireFirstLinkMutable(ContainerBookingEntity container) {
SmartLoadingStatus value = status(container);
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|| repository.hasDocuments(container.getId())) throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能关联订舱");
}
private void requireUnlinkMutable(ContainerBookingEntity container) {
if (status(container) != SmartLoadingStatus.WAIT_LOADING || repository.hasActiveLoadingItems(container.getId())
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|| repository.hasDocuments(container.getId())) throw conflict("已有排货、PDA、证据或单证事实,不能解除订舱关联");
} }
@Transactional @Transactional
@@ -47,7 +47,8 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(bookingId); ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填"); if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填");
return candidates(warehouseId).stream().filter(candidate -> sameChannel(booking, candidate)).toList(); return candidates(warehouseId).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
} }
public Plan draftByBooking(long bookingId) { public Plan draftByBooking(long bookingId) {
@@ -100,6 +101,7 @@ public class SmartLoadingService {
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
requireSameChannel(booking, candidate); requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
@@ -143,6 +145,7 @@ public class SmartLoadingService {
for (Candidate candidate : candidates(booking.getWarehouseId())) { for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue; if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (!sameChannel(booking, candidate)) continue; if (!sameChannel(booking, candidate)) continue;
if (!sameCustomerScope(booking, candidate)) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode()) if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue; && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate); int count = fittingBoxes(booking, repository.items(planId), candidate);
@@ -359,12 +362,18 @@ public class SmartLoadingService {
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode()) return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
&& booking.getChannelCode().equals(candidate.channelCode()); && booking.getChannelCode().equals(candidate.channelCode());
} }
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
}
private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) { private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) { if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填"); throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填");
} }
if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装"); if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装");
} }
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden();
}
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation); LoadingPlanHistoryEntity history = repository.operation(operation);
@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID', ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围',
customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -238,16 +238,29 @@ paths:
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'} schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
'403': {description: 无货柜查询权限} '403': {description: 无货柜查询权限}
post: post:
summary: 新增平台货柜 summary: 新增内部模拟柜
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 WAIT_LOADING(待装柜) description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: {$ref: '#/components/schemas/ContainerSaveRequest'} schema: {$ref: '#/components/schemas/ContainerSimulationCreateRequest'}
responses: responses:
'200': {description: 成功或重复号、参数校验等业务失败} '200': {description: 成功或重复号、参数校验等业务失败}
'403': {description: 无新增按钮权限} '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 等不可逆事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一 containerBookingId/cabinetNo}}
delete:
summary: 解除内部模拟柜的真实订舱关联
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}}
responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}}
/api/platform/v1/containers/{id}: /api/platform/v1/containers/{id}:
get: get:
summary: 查询平台货柜详情 summary: 查询平台货柜详情
@@ -1405,3 +1418,25 @@ components:
assigneeId: {type: integer, format: int64} assigneeId: {type: integer, format: int64}
assigneeName: {type: string, maxLength: 64} assigneeName: {type: string, maxLength: 64}
reason: {type: string, maxLength: 500} reason: {type: string, maxLength: 500}
ContainerSimulationCreateRequest:
type: object
required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg]
properties:
channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道}
warehouseId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, maxLength: 32}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true}
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true}
ContainerOceanBookingLinkRequest:
type: object
required: [oceanBookingId, requirementId, expectedVersion]
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1}
requirementId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0}
ContainerOceanBookingUnlinkRequest:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
@@ -9,6 +9,16 @@ paths:
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}} responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}}
/api/platform/v1/ocean-bookings: /api/platform/v1/ocean-bookings:
get:
summary: List only real confirmed booking records
description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT, SUBMITTED and FAILED are excluded even when a status query is supplied.'
parameters:
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: status, in: query, schema: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses: {'200': {description: Operational real booking page}}
post: post:
summary: Create an ocean booking draft with immutable channel and warehouse snapshots summary: Create an ocean booking draft with immutable channel and warehouse snapshots
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
@@ -16,10 +26,10 @@ paths:
responses: {'200': {description: Draft detail}} responses: {'200': {description: Draft detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/confirm: /api/platform/v1/ocean-bookings/{oceanBookingId}/confirm:
post: post:
summary: Confirm booking and atomically create one stable cabinet and editing loading plan per requested quantity summary: Confirm a real booked container record without creating internal cabinets
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: CONFIRMED detail; no real container number required}} responses: {'200': {description: CONFIRMED real booking detail; internal cabinets are linked explicitly from the container API}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release: /api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post: post:
summary: Confirm released transport data and synchronize associated cabinet transport snapshots summary: Confirm released transport data and synchronize associated cabinet transport snapshots
@@ -21,6 +21,8 @@ class ContainerControllerContractTest {
assertPermission("page", "platform:firstleg:container:list"); assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:list"); assertPermission("detail", "platform:firstleg:container:list");
assertPermission("create", "platform:firstleg:container:create"); assertPermission("create", "platform:firstleg:container:create");
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit");
assertPermission("updateDraft", "platform:firstleg:container:edit"); assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit"); assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("uploadClearance", "platform:firstleg:customs:manage");
@@ -54,6 +56,10 @@ class ContainerControllerContractTest {
assertThat(section) assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:") .contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:") .contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data") .contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest") .contains("ContainerDocumentUploadRequest")
@@ -31,8 +31,7 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"), Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"), Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"), Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
Map.entry("failChange", "platform:firstleg:ocean-booking:change"), Map.entry("failChange", "platform:firstleg:ocean-booking:change"));
Map.entry("allocate", "platform:firstleg:ocean-booking:allocate"));
permissions.forEach((name, permission) -> { permissions.forEach((name, permission) -> {
Method method = method(name); Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value()) assertThat(method.getAnnotation(PreAuthorize.class).value())
@@ -58,6 +57,8 @@ class PlatformOceanBookingControllerContractTest {
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:", assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]") .contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]")
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
.contains("DRAFT, SUBMITTED and FAILED are excluded")
.contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true") .contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true")
.contains("customerName: {type: string, maxLength: 128, nullable: true") .contains("customerName: {type: string, maxLength: 128, nullable: true")
.contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text") .contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text")
@@ -7,14 +7,13 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequiremen
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest;
import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
@@ -144,42 +143,20 @@ class OceanBookingServiceTest {
} }
@Test @Test
void allocatesContainerWithExplicitTimestampAndVersionGuard() { void rejectsDraftAndSubmittedStatusesFromOperationalList() {
when(repository.find(1L)).thenReturn(booking("CONFIRMED", 5), booking("CONFIRMED", 6)); for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ"); .isInstanceOf(BizException.class).hasMessageContaining("仅支持");
requirement.setQuantity(1); }
ContainerBookingEntity container = new ContainerBookingEntity(); verify(repository, never()).page(any());
container.setChannelCode("CHANNEL-A");
container.setId(12L); container.setContainerType("40HQ");
when(repository.requirement(1L, 11L)).thenReturn(requirement);
when(repository.containerBooking(12L)).thenReturn(container);
when(repository.touchForAllocation(1L, 5, 9L, "admin")).thenReturn(1);
Detail result = service.allocate(1L, 9L, "admin", "allocate-1",
new AllocationRequest(11L, 12L, 5));
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository).insert(allocation.capture());
assertThat(allocation.getValue().getOceanBookingId()).isEqualTo(1L);
assertThat(allocation.getValue().getRequirementId()).isEqualTo(11L);
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(12L);
assertThat(allocation.getValue().getAllocatedAt()).isNotNull();
assertThat(result.oceanBooking().getVersion()).isEqualTo(6);
} }
@Test @Test
void rejectsAllocationAfterReleaseBeforeMutation() { void passesOnlyConfirmedLifecycleStatusesToOperationalList() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 5)); service.page(new Query(null, "CHANGE_PENDING", null, 1, 20));
org.mockito.ArgumentCaptor<Query> query = org.mockito.ArgumentCaptor.forClass(Query.class);
assertThatThrownBy(() -> service.allocate(1L, 9L, "admin", "allocate-released", verify(repository).page(query.capture());
new AllocationRequest(11L, 12L, 5))) assertThat(query.getValue().status()).isEqualTo("CHANGE_PENDING");
.isInstanceOf(BizException.class)
.hasMessageContaining("当前订舱状态不可分配柜次");
verify(repository, never()).touchForAllocation(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class));
} }
@Test @Test
@@ -5,6 +5,8 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -13,6 +15,11 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
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.OceanBookingContainerRequirementEntity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -31,6 +38,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料服务") @DisplayName("货柜基础资料服务")
@@ -41,6 +49,8 @@ class ContainerServiceTest {
@MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService; @MockBean private LoadingPickService loadingPickService;
@MockBean private PriceBookQueryRepository priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private ContainerTransportHistoryMapper transportHistories; @MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service; @Resource private ContainerService service;
@@ -51,19 +61,24 @@ class ContainerServiceTest {
} }
@Test @Test
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") @DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号")
void createsContainerBooking() { void createsSimulationContainerBooking() {
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001")); ChannelRow channel = channel();
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel));
ContainerBookingEntity created = service.createSimulation(9L, "admin", simulation());
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture()); verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue(); ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved); assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB"); assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); assertThat(saved.getCabinetNo()).startsWith("CAB-");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运");
assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING"); assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
} }
@@ -210,25 +225,13 @@ class ContainerServiceTest {
.hasMessageContaining("刷新后重试"); .hasMessageContaining("刷新后重试");
} }
@Test
@DisplayName("新增时校验重复集装箱号和内部柜号")
void rejectsDuplicateNumbers() {
when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any());
}
@Test @Test
@DisplayName("虚拟柜拒绝选择非国内中转仓") @DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() { void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong())) when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓")); .thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓"); .hasMessageContaining("国内中转仓");
@@ -238,7 +241,9 @@ class ContainerServiceTest {
@Test @Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准") @DisplayName("虚拟柜仓库名称以仓库主数据为准")
void replacesRequestedWarehouseName() { void replacesRequestedWarehouseName() {
service.create(9L, "admin", request("柜-001")); when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel()));
service.createSimulation(9L, "admin", simulation());
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture()); verify(repository).insert(captor.capture());
@@ -251,13 +256,50 @@ class ContainerServiceTest {
when(warehouses.active(anyString(), anyLong(), anyLong())) when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable")); .thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用"); .hasMessageContaining("暂时不可用");
verify(repository, never()).insert(any()); verify(repository, never()).insert(any());
} }
@Test
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksConfirmedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HQ");
container.setCapacityCbm(new BigDecimal("68.0000")); container.setMaxWeightKg(new BigDecimal("26000.000"));
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("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("26000.000"));
when(repository.find(11L)).thenReturn(container);
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);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 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));
}
@Test
@DisplayName("首次关联遇到有效装柜证据时拒绝")
void rejectsLinkWhenEvidenceExists() {
ContainerBookingEntity container = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId, private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type, String type,
String name) { String name) {
@@ -283,4 +325,12 @@ class ContainerServiceTest {
LocalDateTime.of(2026, 8, 25, 8, 0), null, LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null); LocalDateTime.of(2026, 8, 27, 8, 0), null);
} }
private static SimulationCreateRequest simulation() {
return new SimulationCreateRequest("SEA-US", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("26000.000"));
}
private static ChannelRow channel() {
ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel;
}
} }
@@ -128,6 +128,22 @@ class SmartLoadingServiceTest {
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
} }
@Test
void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() {
LoadingPlanEntity plan = plan("EDITING", 0);
ContainerBookingEntity linked = booking();
linked.setCustomerId(88L);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(linked);
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5));
BizException error = catchThrowableOfType(() -> service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
}
@Test @Test
void allowsVolumeAndWeightWithinOneHundredTenPercent() { void allowsVolumeAndWeightWithinOneHundredTenPercent() {
stubAssignment(); stubAssignment();