This commit is contained in:
2026-08-18 16:39:39 +08:00
parent 7fe5b4e31f
commit a9061eca54
43 changed files with 821 additions and 1217 deletions
@@ -103,9 +103,7 @@ public final class ContainerDtos {
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号")
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {
@Deprecated public OceanBookingLinkRequest(Long legacyRealContainerBookingId, Integer expectedVersion) { this(null, null, null, expectedVersion); }
}
@NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@@ -107,50 +107,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE merged_into_container_booking_id=#{targetId} AND record_status='MERGED' FOR UPDATE")
ContainerBookingEntity selectMergedSourceForUpdate(@Param("targetId") long targetId);
@Update("""
UPDATE fl_container_booking SET record_status='MERGED',
merged_into_container_booking_id=#{targetId}, container_no=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='ACTIVE'
AND merged_into_container_booking_id IS NULL AND container_no IS NOT NULL
""")
int consumeRealSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking target
JOIN fl_container_booking source ON source.id=#{sourceId}
SET target.container_no=#{containerNo}, target.ocean_booking_id=source.ocean_booking_id,
target.ocean_booking_requirement_id=source.ocean_booking_requirement_id,
target.customer_id=source.customer_id, target.customer_name=source.customer_name,
target.bill_of_lading_no=source.bill_of_lading_no,
target.vessel_name=source.vessel_name, target.voyage_no=source.voyage_no,
target.shipping_company_id=source.shipping_company_id,
target.shipping_company_name=source.shipping_company_name,
target.departure_port_code=source.departure_port_code,
target.departure_port_name=source.departure_port_name,
target.destination_port_code=source.destination_port_code,
target.destination_port_name=source.destination_port_name,
target.estimated_sailing_at=source.estimated_sailing_at,
target.estimated_port_arrival_at=source.estimated_port_arrival_at,
target.updated_by=#{operatorId}, target.updated_by_name=#{operatorName},
target.updated_at=CURRENT_TIMESTAMP(3), target.version=target.version+1
WHERE target.id=#{targetId} AND target.version=#{expectedVersion}
AND target.source_type='SIMULATION' AND target.record_status='ACTIVE'
AND target.ocean_booking_id IS NULL AND source.record_status='MERGED'
AND source.merged_into_container_booking_id=target.id
""")
int mergeRealSource(@Param("targetId") long targetId, @Param("sourceId") long sourceId,
@Param("containerNo") String containerNo,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
@@ -164,23 +120,10 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
""")
int clearMergedLink(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
int unlinkReleasedBooking(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET record_status='ACTIVE',
merged_into_container_booking_id=NULL, container_no=#{containerNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='MERGED'
AND merged_into_container_booking_id=#{targetId} AND container_no IS NULL
""")
int restoreMergedSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("containerNo") String containerNo,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
@@ -37,10 +37,6 @@ public final class OceanBookingDtos {
@NotNull @Min(1) @Max(100) Integer quantity,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Deprecated
public record ContainerRequest(String containerNo, String containerType, String containerKind,
BigDecimal capacityCbm, BigDecimal maxWeightKg) {}
public record DraftData(@Positive Long customerId,
@Size(max = 128) String customerName,
@Size(max = 64) String channelCode,
@@ -63,20 +59,7 @@ public final class OceanBookingDtos {
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {
public DraftData(Long customerId, String customerName, String channelCode, Long warehouseId, String billOfLadingNo,
Long shippingCompanyId, String shippingCompanyName, Long bookingAgentId, String bookingAgentName,
String vesselName, String voyageNo, String departurePortCode, String departurePortName,
String destinationPortCode, String destinationPortName, String placeOfDelivery, String serviceTerm,
String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String remark,
List<PartyRequest> parties, java.util.Collection<?> legacyContainers) {
this(customerId, customerName, channelCode, warehouseId, billOfLadingNo, shippingCompanyId, shippingCompanyName,
bookingAgentId, bookingAgentName, vesselName, voyageNo, departurePortCode, departurePortName, destinationPortCode,
destinationPortName, placeOfDelivery, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, remark,
parties, legacyContainers == null ? List.of() : legacyContainers.stream().map(v -> v instanceof ContainerRequirementRequest x ? x : v instanceof ContainerRequest x ? new ContainerRequirementRequest(x.containerType(), x.containerKind(), 1, x.capacityCbm(), x.maxWeightKg()) : null).filter(java.util.Objects::nonNull).toList());
}
public List<ContainerRequest> containers() { return containerRequirements.stream().map(v -> new ContainerRequest(null, v.containerType(), v.containerKind(), v.capacityCbm(), v.maxWeightKg())).toList(); }
}
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
@@ -85,14 +68,7 @@ public final class OceanBookingDtos {
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {
public ReleaseRequest(int expectedVersion, String bill, String vessel, String voyage, LocalDateTime etd, LocalDateTime eta, String reason) {
this(expectedVersion, reason);
}
}
@Deprecated
public record ChangeRequest(Integer expectedVersion, String vesselName, String voyageNo,
LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String reason) {}
@Size(max = 1000) String reason) {}
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
Long requirementId, Long allocationId, String containerType,
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
@@ -40,12 +40,6 @@ public class OceanBookingEntity {
private String freightTerm;
private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt;
private String pendingVesselName;
private String pendingVoyageNo;
private LocalDateTime pendingEstimatedSailingAt;
private LocalDateTime pendingEstimatedArrivalAt;
private String changeReason;
private String cancellationReason;
private String remark;
private Long createdBy;
private String createdByName;
@@ -43,13 +43,12 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},
cancellation_reason=#{cancellationReason},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status=#{fromStatus} AND version=#{expectedVersion}
""")
int transition(@Param("oceanBookingId") long oceanBookingId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("cancellationReason") String cancellationReason,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -19,7 +19,7 @@ public interface OceanBookingOperationMapper extends BaseMapper<OceanBookingOper
@Update("UPDATE fl_ocean_booking_operation SET ocean_booking_id=#{oceanBookingId},status='SUCCESS',"
+ "result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("oceanBookingId") long oceanBookingId, @Param("operatorId") long operatorId,
int complete(@Param("oceanBookingId") Long oceanBookingId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key,
@Param("resultJson") String resultJson);
}
@@ -31,20 +31,14 @@ public class OceanBookingRepository {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
@Deprecated public int release(long id, Object ignored, long operatorId, String operatorName) { return 0; }
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, String cancellationReason,
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, cancellationReason, version, operatorId, operatorName);
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, version, operatorId, operatorName);
}
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateDraft(value.getId(), value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) {
allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id));
requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id));
parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id));
histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id));
return bookings.deleteDraft(id, version);
}
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
@@ -86,5 +80,5 @@ public class OceanBookingRepository {
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)); }
public int completeOperation(long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
public int completeOperation(Long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
}
@@ -112,18 +112,15 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
@Deprecated public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) { throw conflict("订舱仅支持DRAFT和RELEASED,不能取消"); }
@Deprecated public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) { throw conflict("RELEASED订舱不可变更"); }
@Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
Detail replay = claimOrReplay(null, operatorId, action, key, hash(action, id, request));
if (replay != null) return;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
if (repository.deleteDraft(id, request.expectedVersion()) != 1) throw concurrent();
complete(id, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
complete(null, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
@@ -134,7 +131,7 @@ public class OceanBookingService {
requireStatus(before, OceanBookingStatus.DRAFT);
validateRelease(before, repository.parties(id), repository.requirements(id));
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
@@ -153,7 +150,7 @@ public class OceanBookingService {
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取订舱幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String action, String key, Detail detail) {
private Detail complete(Long id, long operatorId, String action, String key, Detail detail) {
try {
if (repository.completeOperation(id, operatorId, action, key, objectMapper.writeValueAsString(detail)) != 1) {
throw conflict("订舱幂等请求完成状态冲突");
@@ -259,50 +256,6 @@ public class OceanBookingService {
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); 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) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
/* real container rows are created only by direct container linking after release. */
/*
for (int index = 0; index < requests.size(); index++) {
ContainerRequirementRequest request = requests.get(index);
OceanBookingContainerRequirementEntity requirement = requirements.get(index);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + container.getId());
container.setContainerNo(request.containerNo().trim());
container.setSourceType("OCEAN_BOOKING"); container.setRecordStatus("ACTIVE");
container.setChannelCode(booking.getChannelCode()); container.setChannelName(booking.getChannelName());
container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setBillOfLadingNo(booking.getBillOfLadingNo());
container.setVesselName(booking.getVesselName()); container.setVoyageNo(booking.getVoyageNo());
container.setContainerType(requirement.getContainerType());
container.setShippingCompanyId(booking.getShippingCompanyId());
container.setShippingCompanyName(booking.getShippingCompanyName());
container.setDeparturePortCode(booking.getDeparturePortCode());
container.setDeparturePortName(booking.getDeparturePortName());
container.setDestinationPortCode(booking.getDestinationPortCode());
container.setDestinationPortName(booking.getDestinationPortName());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
container.setEstimatedSailingAt(booking.getEstimatedSailingAt());
container.setEstimatedPortArrivalAt(booking.getEstimatedArrivalAt());
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
try { repository.insertContainer(container); }
catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在:" + request.containerNo().trim());
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
allocation.setRequirementId(requirement.getId());
allocation.setContainerBookingId(container.getId()); allocation.setAllocatedBy(operatorId);
allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now());
repository.insert(allocation);
}
}
*/
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
@@ -18,9 +18,6 @@ public class ContainerRepository {
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public ContainerBookingEntity mergedSourceForUpdate(long targetId) {
return mapper.selectMergedSourceForUpdate(targetId);
}
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -33,19 +30,8 @@ public class ContainerRepository {
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int consumeRealSource(long sourceId, long targetId, long operatorId, String operatorName) {
return mapper.consumeRealSource(sourceId, targetId, operatorId, operatorName);
}
public int mergeRealSource(long targetId, long sourceId, String containerNo, int version,
long operatorId, String operatorName) {
return mapper.mergeRealSource(targetId, sourceId, containerNo, version, operatorId, operatorName);
}
public int clearMergedLink(long targetId, int version, long operatorId, String operatorName) {
return mapper.clearMergedLink(targetId, version, operatorId, operatorName);
}
public int restoreMergedSource(long sourceId, long targetId, String containerNo,
long operatorId, String operatorName) {
return mapper.restoreMergedSource(sourceId, targetId, containerNo, operatorId, operatorName);
public int unlinkReleasedBooking(long targetId, int version, long operatorId, String operatorName) {
return mapper.unlinkReleasedBooking(targetId, version, operatorId, operatorName);
}
public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo,
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking,
@@ -107,14 +107,10 @@ public class ContainerService {
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
ContainerBookingEntity source = repository.mergedSourceForUpdate(id);
if (source == null) throw conflict("真实订舱柜合并来源不存在,不能解除关联");
String containerNo = container.getContainerNo();
if (repository.clearMergedLink(id, request.expectedVersion(), operatorId, operatorName) != 1
|| repository.restoreMergedSource(source.getId(), id, containerNo, operatorId, operatorName) != 1
|| oceanBookings.reassignAllocation(id, source.getId()) != 1) {
if (repository.unlinkReleasedBooking(id, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
oceanBookings.deleteAllocation(id);
return required(id);
}
@@ -218,6 +218,7 @@ public class SmartLoadingService {
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String remark = trim(request.warehouseRemark());
if (!StringUtils.hasText(remark)) remark = null;
String requestHash = sha256("WAREHOUSE_REMARK|" + planId + '|' + itemId + '|'
+ Objects.toString(remark, "") + '|' + request.expectedPlanVersion() + '|'
+ request.expectedItemVersion());
@@ -27,12 +27,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号',
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
@@ -49,10 +43,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
(pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL
OR pending_estimated_arrival_at >= pending_estimated_sailing_at)
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
-- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional.
@@ -60,10 +51,7 @@ ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
-- Reconcile databases created before the direct-CONFIRMED contract. This is
-- intentionally fail-closed: no legacy row is promoted and no transport term
-- is guessed. Referenced legacy aggregates must be archived/deleted by the
-- owning module first, then this block can be rerun safely.
-- Recreate triggers and the current two-state constraint on repeatable initialization.
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
@@ -112,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY'))
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
@@ -127,7 +115,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0)
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
@@ -142,7 +130,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id)
REFERENCES fl_ocean_booking_container_requirement(id),
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
@@ -162,7 +150,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
@@ -179,7 +167,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希';
@@ -188,7 +176,7 @@ DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -256,8 +244,8 @@ BEGIN
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete'
WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate'
END)
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
@@ -278,8 +266,8 @@ VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑草稿',NULL,'PUT',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:delete','删除草稿',NULL,'DELETE',80,'ACTIVE',1,0),
(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
@@ -251,14 +251,14 @@ paths:
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜原地合并为指定真实订舱柜
description: 需要 platform:firstleg:container:edit;请求指定未消费且尚无装柜计划/PDA/证据/单证的 realContainerBookingId。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,将真实柜 containerNo、订舱、运输和范围快照原子复制到模拟柜并把 allocation 改指模拟柜;原真实柜置 MERGED 且不再出现在正常货柜列表。存在跨仓、跨渠道、柜型规格不一致、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 合并后的同一模拟柜 containerBookingId/cabinetNo;列表仅保留这一条 ACTIVE 操作身份}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
delete:
summary: 解除内部模拟柜的真实订舱关联
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;原子清除当前柜订舱/运输快照和 allocation,不恢复或创建其他柜记录。任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}}
responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}}
@@ -408,7 +408,11 @@ paths:
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
patch:
summary: 修改装柜明细仓库备注
description: EDITING/LOADING 可修改或清空 warehouseRemark;COMPLETED、计划/明细版本冲突返回 A0409,写入历史且与 PDA final complete 使用同一并发门禁。
description: >-
需要 platform:firstleg:preload:edit 权限。EDITING/LOADING 可修改或清空 warehouseRemark;
COMPLETED、计划/明细版本冲突返回 A0409 且事务零写入,写入历史且与 PDA final complete 使用同一并发门禁。
成功响应中的 version 是递增后的 planVersion,items[].version 是递增后的 itemVersion;调用方同时使用
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有 warehouseRemark。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -29,7 +29,17 @@ paths:
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/channels:
get: {summary: List ACTIVE channels for an origin warehouse, responses: {'200': {description: Channel options}}}
get:
summary: List ACTIVE channels for an origin warehouse
description: Requires platform:firstleg:ocean-booking:list. Channel names are resolved from the current ACTIVE price book and frozen by the server on save.
parameters:
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: Channel options
content:
application/json:
schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -48,8 +58,15 @@ components:
billOfLadingNo: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
bookingAgentId: {type: integer, format: int64, nullable: true}
bookingAgentName: {type: string, nullable: true}
bookingAgentId:
type: integer
format: int64
nullable: true
description: Warehouse supplier ID; release validates ACTIVE category BOOKING; booking suppliers do not use domestic/international scope.
bookingAgentName:
type: string
nullable: true
description: Read-only consistency hint; the persisted snapshot always comes from Warehouse.
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
departurePortCode: {type: string, nullable: true}
@@ -68,4 +85,13 @@ components:
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ChannelOption:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
properties:
priceBookVersionId: {type: integer, format: int64}
channelCode: {type: string}
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}