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 '订舱渠道名称快照',
ocean_booking_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_name VARCHAR(128) NOT 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.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -88,9 +90,15 @@ public class PlatformContainerController {
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody SaveRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request));
@Valid @RequestBody SimulationCreateRequest 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')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
@@ -92,4 +92,18 @@ public final class ContainerDtos {
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 Long oceanBookingId;
private Long oceanBookingRequirementId;
private Long customerId;
private String customerName;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
@@ -24,7 +24,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE (#{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 EXISTS (
AND (#{query.customerId} IS NULL OR b.customer_id = #{query.customerId} OR EXISTS (
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
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,
@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 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')")
@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)); }
@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); }
}
@@ -83,9 +83,6 @@ public final class OceanBookingDtos {
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@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,
List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> containerRequirements,
@@ -17,6 +17,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
<script>
SELECT * FROM fl_ocean_booking
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.keyword} IS NULL OR booking_no 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,
@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.extension.plugins.pagination.Page;
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.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.entity.*;
@@ -24,8 +20,6 @@ public class OceanBookingRepository {
@Resource private OceanBookingContainerAllocationMapper allocations;
@Resource private OceanBookingStatusHistoryMapper histories;
@Resource private OceanBookingOperationMapper operations;
@Resource private ContainerBookingMapper containerBookings;
@Resource private LoadingPlanMapper loadingPlans;
public PageResult<OceanBookingEntity> page(Query 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) {
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<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)); }
@@ -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 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 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) {
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
}
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 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)); }
@@ -4,8 +4,6 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
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.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -34,6 +31,9 @@ import java.util.Set;
@Service
public class OceanBookingService {
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 PriceBookQueryRepository priceBooks;
@Resource private ObjectMapper objectMapper;
@@ -41,7 +41,9 @@ public class OceanBookingService {
public PageResult<OceanBookingEntity> page(Query query) {
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()));
}
@@ -116,7 +118,6 @@ public class OceanBookingService {
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
generateContainers(after, repository.requirements(id), operatorId, name);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
return complete(id, operatorId, action, key, detail(after));
@@ -210,33 +211,6 @@ public class OceanBookingService {
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,
OceanBookingStatus from, OceanBookingStatus to, ActionRequest request,
boolean failure) {
@@ -348,44 +322,6 @@ public class OceanBookingService {
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,
List<OceanBookingContainerRequirementEntity> requirements) {
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<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); } }
@@ -32,6 +32,13 @@ public class ContainerRepository {
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 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,
long operatorId, String 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.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
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.LoadingPickRepository;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -34,6 +42,8 @@ public class ContainerService {
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickRepository loadingPickRepository;
@Resource private LoadingPickService loadingPickService;
@Resource private PriceBookQueryRepository priceBooks;
@Resource private OceanBookingRepository oceanBookings;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -44,20 +54,70 @@ public class ContainerService {
public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
validateSchedule(request);
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId());
copy(value, request);
Long versionId = priceBooks.activeVersionId();
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream()
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
.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.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name());
return value;
value.setContainerType(request.containerType().trim()); value.setCapacityCbm(request.capacityCbm());
value.setMaxWeightKg(request.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value);
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); 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
@@ -47,7 +47,8 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) 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) {
@@ -100,6 +101,7 @@ public class SmartLoadingService {
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
@@ -143,6 +145,7 @@ public class SmartLoadingService {
for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (!sameChannel(booking, candidate)) continue;
if (!sameCustomerScope(booking, candidate)) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate);
@@ -359,12 +362,18 @@ public class SmartLoadingService {
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(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) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
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 Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation);
@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_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_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -238,16 +238,29 @@ paths:
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
'403': {description: 无货柜查询权限}
post:
summary: 新增平台货柜
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 WAIT_LOADING(待装柜)
summary: 新增内部模拟柜
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerSaveRequest'}
schema: {$ref: '#/components/schemas/ContainerSimulationCreateRequest'}
responses:
'200': {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}:
get:
summary: 查询平台货柜详情
@@ -1405,3 +1418,25 @@ components:
assigneeId: {type: integer, format: int64}
assigneeName: {type: string, maxLength: 64}
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}}
responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}}
/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:
summary: Create an ocean booking draft with immutable channel and warehouse snapshots
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
@@ -16,10 +26,10 @@ paths:
responses: {'200': {description: Draft detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/confirm:
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'}]
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:
post:
summary: Confirm released transport data and synchronize associated cabinet transport snapshots
@@ -21,6 +21,8 @@ class ContainerControllerContractTest {
assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:list");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
@@ -54,6 +56,10 @@ 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 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
@@ -31,8 +31,7 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
Map.entry("failChange", "platform:firstleg:ocean-booking:change"),
Map.entry("allocate", "platform:firstleg:ocean-booking:allocate"));
Map.entry("failChange", "platform:firstleg:ocean-booking:change"));
permissions.forEach((name, permission) -> {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
@@ -58,6 +57,8 @@ class PlatformOceanBookingControllerContractTest {
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.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("customerName: {type: string, maxLength: 128, nullable: true")
.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.Detail;
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.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest;
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.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
@@ -144,42 +143,20 @@ class OceanBookingServiceTest {
}
@Test
void allocatesContainerWithExplicitTimestampAndVersionGuard() {
when(repository.find(1L)).thenReturn(booking("CONFIRMED", 5), booking("CONFIRMED", 6));
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ");
requirement.setQuantity(1);
ContainerBookingEntity container = new ContainerBookingEntity();
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);
void rejectsDraftAndSubmittedStatusesFromOperationalList() {
for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
}
verify(repository, never()).page(any());
}
@Test
void rejectsAllocationAfterReleaseBeforeMutation() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 5));
assertThatThrownBy(() -> service.allocate(1L, 9L, "admin", "allocate-released",
new AllocationRequest(11L, 12L, 5)))
.isInstanceOf(BizException.class)
.hasMessageContaining("当前订舱状态不可分配柜次");
verify(repository, never()).touchForAllocation(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class));
void passesOnlyConfirmedLifecycleStatusesToOperationalList() {
service.page(new Query(null, "CHANGE_PENDING", null, 1, 20));
org.mockito.ArgumentCaptor<Query> query = org.mockito.ArgumentCaptor.forClass(Query.class);
verify(repository).page(query.capture());
assertThat(query.getValue().status()).isEqualTo("CHANGE_PENDING");
}
@Test
@@ -5,6 +5,8 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
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.LoadingPickRepository;
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 org.junit.jupiter.api.DisplayName;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料服务")
@@ -41,6 +49,8 @@ class ContainerServiceTest {
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService;
@MockBean private PriceBookQueryRepository priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@@ -51,19 +61,24 @@ class ContainerServiceTest {
}
@Test
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
void createsContainerBooking() {
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001"));
@DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号")
void createsSimulationContainerBooking() {
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);
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
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.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
}
@@ -210,25 +225,13 @@ class ContainerServiceTest {
.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
@DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
@@ -238,7 +241,9 @@ class ContainerServiceTest {
@Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
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);
verify(repository).insert(captor.capture());
@@ -251,13 +256,50 @@ class ContainerServiceTest {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用");
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,
String type,
String name) {
@@ -283,4 +325,12 @@ class ContainerServiceTest {
LocalDateTime.of(2026, 8, 25, 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());
}
@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
void allowsVolumeAndWeightWithinOneHundredTenPercent() {
stubAssignment();