This commit is contained in:
2026-08-12 17:29:30 +08:00
parent 4562e3f1d1
commit b5649cf8ae
42 changed files with 1205 additions and 78 deletions
@@ -60,6 +60,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.pendingTasks(user.userId(), keyword));
}
@GetMapping("/tasks/{taskId}")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> taskById(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(taskId, warehouseId));
}
@GetMapping("/tasks/{taskId}/inventory-snapshot")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<InventorySnapshot> inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user,
@@ -84,6 +84,7 @@ public final class ContainerDtos {
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version
@NotNull @PositiveOrZero Integer version,
@NotBlank @Size(max = 500) String reason
) {}
}
@@ -62,6 +62,7 @@ public final class LoadingListDtos {
public record PreloadSummary(
Long planId,
Long containerBookingId,
String preloadNo,
String containerNo,
String internalContainerNo,
@@ -86,6 +87,7 @@ public final class LoadingListDtos {
public record ConfirmedSummary(
Long planId,
Long containerBookingId,
String loadingListNo,
Long warehouseId,
String warehouseName,
@@ -58,7 +58,9 @@ public final class LoadingPickDtos {
/** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */
public record BatchRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String containerNo,
@NotNull @Min(1) Long loadingPlanId,
@NotNull @Min(1) Long containerBookingId,
@Size(max = 64) String containerNo,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String inventorySnapshotToken,
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
@@ -125,13 +127,15 @@ public final class LoadingPickDtos {
Long operatorId, String operatorName, LocalDateTime createdAt) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
public record Task(Long id, Long loadingPlanId, Long containerBookingId, String cabinetNo,
String currentContainerNo, String taskStatus,
String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
}
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
public record PendingTask(Long taskId, Long loadingPlanId, String containerNo,
public record PendingTask(Long taskId, Long loadingPlanId, Long containerBookingId,
String cabinetNo, String currentContainerNo,
Long warehouseId, String warehouseName, String taskStatus,
String workflowStatus, Integer version, Integer assignmentVersion,
Integer itemCount, Integer pendingItemCount) {
@@ -146,7 +150,8 @@ public final class LoadingPickDtos {
List<InventoryResource> resources) {
}
public record InventorySnapshot(Long taskId, Integer taskVersion, String containerNo,
public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId,
String cabinetNo, Integer taskVersion, String currentContainerNo,
Long warehouseId, LocalDateTime capturedAt, String snapshotToken,
List<InboundInventory> inbounds) {
}
@@ -2,12 +2,12 @@ package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
@@ -20,6 +20,10 @@ public class ContainerBookingEntity {
private String bookingNo;
private String containerNo;
private String cabinetNo;
private String channelCode;
private String channelName;
private Long oceanBookingId;
private Long oceanBookingRequirementId;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
@TableName("fl_container_transport_history")
public class ContainerTransportHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long containerBookingId;
private String beforeContainerNo;
private String afterContainerNo;
private String beforeVoyageNo;
private String afterVoyageNo;
private String reason;
private Long operatorId;
private String operatorName;
}
@@ -17,6 +17,8 @@ public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long loadingPlanId;
private Long containerBookingId;
private String cabinetNo;
private String containerNo;
private String taskStatus;
private Long startedBy;
@@ -96,4 +96,15 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},
voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},updated_by=#{operatorId},
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE ocean_booking_id=#{oceanBookingId}
""")
int syncReleasedTransport(@Param("oceanBookingId") long oceanBookingId,
@Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName,
@Param("voyageNo") String voyageNo, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {}
@@ -24,7 +24,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
WHERE booking.container_no = #{containerNo} AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
@@ -34,7 +34,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
@Select("""
<script>
SELECT task.id AS taskId, task.loading_plan_id AS loadingPlanId,
task.container_no AS containerNo, booking.warehouse_id AS warehouseId,
task.container_booking_id AS containerBookingId, task.cabinet_no AS cabinetNo,
booking.container_no AS containerNo, booking.warehouse_id AS warehouseId,
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
CASE WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus,
task.version AS version, COALESCE(assignment.version, 0) AS assignmentVersion,
@@ -51,7 +52,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
AND (task.container_no LIKE CONCAT('%', #{keyword}, '%')
OR booking.warehouse_name LIKE CONCAT('%', #{keyword}, '%'))
</if>
GROUP BY task.id, task.loading_plan_id, task.container_no, booking.warehouse_id,
GROUP BY task.id, task.loading_plan_id, task.container_booking_id, task.cabinet_no,
booking.container_no, booking.warehouse_id,
booking.warehouse_name, task.task_status, task.version,
assignment.pick_task_id, assignment.version
ORDER BY task.id DESC
@@ -65,6 +67,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
private Long taskId;
private Long loadingPlanId;
private String containerNo;
private Long containerBookingId;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String taskStatus;
@@ -80,6 +84,10 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
public void setLoadingPlanId(Long loadingPlanId) { this.loadingPlanId = loadingPlanId; }
public String getContainerNo() { return containerNo; }
public void setContainerNo(String containerNo) { this.containerNo = containerNo; }
public Long getContainerBookingId() { return containerBookingId; }
public void setContainerBookingId(Long value) { this.containerBookingId = value; }
public String getCabinetNo() { return cabinetNo; }
public void setCabinetNo(String value) { this.cabinetNo = value; }
public Long getWarehouseId() { return warehouseId; }
public void setWarehouseId(Long warehouseId) { this.warehouseId = warehouseId; }
public String getWarehouseName() { return warehouseName; }
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -42,7 +42,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -43,7 +43,7 @@ public class PlatformOceanBookingController {
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')")
@BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱")
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')")
@BusinessAudit(event="OCEAN_BOOKING_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记海运订舱失败")
public ApiResponse<Detail> fail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.fail(oceanBookingId, user.userId(), user.username(), key, request)); }
@@ -15,6 +15,7 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
public final class OceanBookingDtos {
@@ -34,10 +35,16 @@ public final class OceanBookingDtos {
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@NotNull @Positive Integer quantity) {}
public record DraftData(@NotNull @Positive Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@@ -64,6 +71,13 @@ public final class OceanBookingDtos {
@Valid @NotNull DraftData data) {}
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String billOfLadingNo,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotNull LocalDateTime estimatedSailingAt,
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String reason) {}
public record ChangeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@@ -14,6 +15,8 @@ public class OceanBookingContainerRequirementEntity {
private Long oceanBookingId;
private String containerType;
private String containerKind;
private BigDecimal capacityCbm;
private BigDecimal maxWeightKg;
private Integer quantity;
private Integer sortNo;
}
@@ -20,6 +20,10 @@ public class OceanBookingEntity {
private String status;
private Long customerId;
private String customerName;
private String channelCode;
private String channelName;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
private Long shippingCompanyId;
private String shippingCompanyName;
@@ -30,6 +30,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Update("""
UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},
channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},
shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},
booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
@@ -56,6 +57,20 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='RELEASED',bill_of_lading_no=#{billOfLadingNo},
vessel_name=#{vesselName},voyage_no=#{voyageNo},estimated_sailing_at=#{estimatedSailingAt},
estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion}
""")
int release(@Param("oceanBookingId") long oceanBookingId, @Param("billOfLadingNo") String billOfLadingNo,
@Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo,
@Param("estimatedSailingAt") LocalDateTime estimatedSailingAt,
@Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='CHANGE_PENDING',pending_vessel_name=#{vesselName},
pending_voyage_no=#{voyageNo},pending_estimated_sailing_at=#{estimatedSailingAt},
@@ -5,7 +5,10 @@ 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.*;
import com.baiyue.firstleg.oceanbooking.mapper.*;
import jakarta.annotation.Resource;
@@ -22,6 +25,7 @@ public class OceanBookingRepository {
@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));
@@ -35,6 +39,11 @@ public class OceanBookingRepository {
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, failureReason, cancellationReason, version, operatorId, operatorName);
}
public int release(long id, ReleaseRequest request, long operatorId, String operatorName) {
return bookings.release(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), request.estimatedArrivalAt(),
request.expectedVersion(), operatorId, operatorName);
}
public int requestChange(long id, OceanBookingEntity pending, String reason, int version,
long operatorId, String operatorName) {
return bookings.requestChange(id, pending.getPendingVesselName(), pending.getPendingVoyageNo(),
@@ -55,6 +64,12 @@ public class OceanBookingRepository {
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 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; }
@@ -5,6 +5,7 @@ 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.*;
@@ -82,12 +83,32 @@ public class OceanBookingService {
OceanBookingStatus.SUBMITTED, request, false);
}
@Transactional public Detail confirm(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "CONFIRM", OceanBookingStatus.SUBMITTED,
OceanBookingStatus.CONFIRMED, request, false);
String action = "CONFIRM";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.SUBMITTED);
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));
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "RELEASE", OceanBookingStatus.CONFIRMED,
OceanBookingStatus.RELEASED, request, false);
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt());
String action = "RELEASE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CONFIRMED);
if (repository.release(id, request, operatorId, name) != 1) throw concurrent();
repository.syncReleasedTransport(id, request, operatorId, name);
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
@@ -177,6 +198,7 @@ public class OceanBookingService {
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();
@@ -237,7 +259,11 @@ public class OceanBookingService {
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
private void validateDraft(DraftData data) {
validateRange(data.estimatedSailingAt(), data.estimatedArrivalAt());
if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null
|| StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName())
|| StringUtils.hasText(data.voyageNo())) {
throw badRequest("真实运输数据请在确认放舱时录入");
}
Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) {
String type = party.partyType().trim().toUpperCase();
@@ -254,17 +280,66 @@ public class OceanBookingService {
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void copy(OceanBookingEntity v, DraftData d) {
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); v.setBillOfLadingNo(trim(d.billOfLadingNo()));
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim());
v.setChannelCode(d.channelCode().trim()); v.setChannelName(d.channelName().trim());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
v.setBillOfLadingNo(null);
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName()));
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setVesselName(null); v.setVoyageNo(null);
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName()));
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm()));
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setRemark(trim(d.remark()));
v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark()));
}
private void generateContainers(OceanBookingEntity booking,
List<OceanBookingContainerRequirementEntity> requirements,
long operatorId, String operatorName) {
for (OceanBookingContainerRequirementEntity requirement : requirements) {
for (int sequence = 1; sequence <= requirement.getQuantity(); sequence++) {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + booking.getBookingNo() + '-' + requirement.getSortNo() + '-' + sequence);
container.setContainerNo(null); container.setChannelCode(booking.getChannelCode());
container.setChannelName(booking.getChannelName()); container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setContainerType(requirement.getContainerType());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
container.setShippingCompanyId(booking.getShippingCompanyId());
container.setShippingCompanyName(booking.getShippingCompanyName());
container.setDeparturePortCode(booking.getDeparturePortCode());
container.setDeparturePortName(booking.getDeparturePortName());
container.setDestinationPortCode(booking.getDestinationPortCode());
container.setDestinationPortName(booking.getDestinationPortName());
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
repository.insert(container);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + container.getId()); plan.setContainerBookingId(container.getId());
plan.setPlanStatus("EDITING"); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); plan.setVersion(0);
repository.insert(plan);
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(container.getId());
allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName);
allocation.setAllocatedAt(LocalDateTime.now()); repository.insert(allocation);
}
}
}
private static void 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.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private String hash(String action, Long id, Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action + '|' + id + '|' + objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("无法计算订舱请求摘要", ex); } }
private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } }
private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } }
@@ -102,7 +102,8 @@ public class LoadingListRepository {
}
private PreloadSummary preload(AggregateRow row, List<Customer> customers) {
return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(),
return new PreloadSummary(row.getPlanId(), row.getContainerBookingId(), row.getPlanNo(),
row.getContainerNo(), row.getCabinetNo(),
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
@@ -110,7 +111,8 @@ public class LoadingListRepository {
}
private ConfirmedSummary confirmed(AggregateRow row) {
return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(),
return new ConfirmedSummary(row.getPlanId(), row.getContainerBookingId(), row.getPlanNo(),
row.getWarehouseId(), row.getWarehouseName(),
row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()),
decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()),
decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(),
@@ -13,6 +13,8 @@ import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
@@ -29,6 +31,7 @@ public class ContainerService {
@Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickService loadingPickService;
@Resource private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
@@ -87,6 +90,11 @@ public class ContainerService {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity();
history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo());
history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo());
history.setAfterVoyageNo(trim(request.voyageNo())); history.setReason(request.reason().trim());
history.setOperatorId(operatorId); history.setOperatorName(operatorName); transportHistories.insert(history);
ContainerBookingEntity updated = required(id);
beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName);
return updated;
@@ -80,7 +80,10 @@ public class LoadingPickService {
if (task == null) {
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
task.setContainerNo(containerNo);
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
task.setContainerBookingId(booking.getId());
task.setCabinetNo(booking.getCabinetNo());
task.setContainerNo(null);
task.setTaskStatus("LOADING");
task.setStartedBy(operatorId);
task.setStartedByName(operatorName);
@@ -122,10 +125,15 @@ public class LoadingPickService {
return view(task, warehouseId);
}
public Task task(long taskId, long warehouseId) {
return view(requiredTask(taskId), warehouseId);
}
public List<PendingTask> pendingTasks(long operatorId, String keyword) {
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
return repository.pendingTasks(operatorId, normalizedKeyword).stream()
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerNo(),
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(),
row.getCabinetNo(), row.getContainerNo(),
row.getWarehouseId(), row.getWarehouseName(), row.getTaskStatus(), row.getWorkflowStatus(),
row.getVersion(), row.getAssignmentVersion(), row.getItemCount(), row.getPendingItemCount()))
.toList();
@@ -297,8 +305,9 @@ public class LoadingPickService {
if (!Objects.equals(request.expectedVersion(), task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后重试");
}
if (!task.getContainerNo().equals(request.containerNo().trim())) {
throw conflict("柜号与PDA任务不一致");
if (!Objects.equals(request.loadingPlanId(), task.getLoadingPlanId())
|| !Objects.equals(request.containerBookingId(), task.getContainerBookingId())) {
throw conflict("装柜计划或柜次归属与PDA任务不一致");
}
LoadingPickAssignmentEntity assignment = repository.assignment(taskId);
if (assignment == null) throw conflict("请先领取PDA拣货任务");
@@ -501,7 +510,9 @@ public class LoadingPickService {
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
: assigned == null ? "UNASSIGNED"
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
var booking = smartLoadingRepository.booking(task.getContainerBookingId());
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(),
booking == null ? null : booking.getContainerNo(), task.getTaskStatus(),
workflowStatus, task.getVersion(), assignment, review, items);
}
@@ -613,7 +624,9 @@ public class LoadingPickService {
return new InboundInventory(inboundId, available, resources);
}).toList();
String token = inventoryToken(task, warehouseId, inbounds);
return new InventorySnapshot(task.getId(), task.getVersion(), task.getContainerNo(), warehouseId,
var booking = smartLoadingRepository.booking(task.getContainerBookingId());
return new InventorySnapshot(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(),
task.getVersion(), booking == null ? null : booking.getContainerNo(), warehouseId,
LocalDateTime.now(), token, inbounds);
}
@@ -808,13 +821,13 @@ public class LoadingPickService {
}
private static String batchRequestHash(long taskId, BatchRequest request) {
if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo())
if (request == null || request.warehouseId() == null
|| request.expectedVersion() == null || !StringUtils.hasText(request.inventorySnapshotToken())
|| request.items() == null || request.items().isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整");
}
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
.append(request.warehouseId()).append('|').append(request.containerNo().trim()).append('|')
.append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|')
.append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim());
request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
Comparator.nullsFirst(Long::compareTo)))
@@ -93,6 +93,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
requireSameChannel(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());
@@ -135,6 +136,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (!sameChannel(booking, candidate)) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate);
@@ -170,6 +172,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
@@ -287,6 +290,7 @@ public class SmartLoadingService {
}
}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,int count){
requireSameChannel(b, c);
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致");
BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
@@ -344,6 +348,16 @@ public class SmartLoadingService {
return p;
}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) {
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
&& booking.getChannelCode().equals(candidate.channelCode());
}
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 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);
@@ -0,0 +1,57 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers;
DELIMITER $$
CREATE PROCEDURE migrate_phase3_channel_containers()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_ocean_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER customer_name,
ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code,
ADD COLUMN warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照' AFTER channel_name,
ADD COLUMN warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照' AFTER warehouse_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking_container_requirement' AND column_name='capacity_cbm') THEN
ALTER TABLE fl_ocean_booking_container_requirement ADD COLUMN capacity_cbm DECIMAL(18,4) NULL COMMENT '柜型容量快照m3' AFTER container_kind,
ADD COLUMN max_weight_kg DECIMAL(18,3) NULL COMMENT '柜型最大承重快照kg' AFTER capacity_cbm;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no,
ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code,
ADD COLUMN ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID' AFTER channel_name,
ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id;
END IF;
UPDATE fl_container_booking c
JOIN fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id
JOIN fl_ocean_booking b ON b.id=a.ocean_booking_id
SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,
c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name)
WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL OR c.channel_code IS NULL;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN
ALTER TABLE fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id,
ADD COLUMN cabinet_no VARCHAR(64) NULL COMMENT '稳定内部柜次号快照' AFTER container_booking_id,
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份';
END IF;
UPDATE fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id
JOIN fl_container_booking c ON c.id=p.container_booking_id
SET t.container_booking_id=c.id,t.cabinet_no=c.cabinet_no
WHERE t.container_booking_id IS NULL OR t.cabinet_no IS NULL;
IF EXISTS (SELECT 1 FROM fl_loading_pick_task WHERE container_booking_id IS NULL OR cabinet_no IS NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='渠道柜迁移失败:PDA任务无法回填稳定柜次归属';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_loading_pick_task' AND index_name='uk_loading_pick_task_booking') THEN
ALTER TABLE fl_loading_pick_task ADD UNIQUE KEY uk_loading_pick_task_booking (container_booking_id);
END IF;
END$$
DELIMITER ;
CALL migrate_phase3_channel_containers();
DROP PROCEDURE IF EXISTS migrate_phase3_channel_containers;
CREATE TABLE IF NOT EXISTS fl_container_transport_history (
id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL,
before_container_no VARCHAR(64) NULL, after_container_no VARCHAR(64) NULL,
before_voyage_no VARCHAR(64) NULL, after_voyage_no VARCHAR(64) NULL,
reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
KEY idx_container_transport_history (container_booking_id,created_at,id)
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
@@ -7,6 +7,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照',
channel_name VARCHAR(128) NOT 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 '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
@@ -71,6 +75,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码',
container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3',
max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg',
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
@@ -31,6 +31,10 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -396,7 +400,9 @@ DROP PROCEDURE migrate_legacy_loading_pick_task;
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
@@ -407,6 +413,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
@@ -953,6 +953,10 @@ components:
bookingNo: {type: string, maxLength: 40}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
channelName: {type: string, maxLength: 128, nullable: true}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
@@ -1017,11 +1021,12 @@ components:
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
ContainerTransportUpdateRequest:
type: object
required: [version]
required: [version, reason]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500}
MerchantInboundBillingEnvelope:
type: object
properties:
@@ -208,9 +208,10 @@ components:
customerName: {type: string, nullable: true, description: 客户名称}
PreloadSummary:
type: object
required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
required: [planId, containerBookingId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
@@ -229,9 +230,10 @@ components:
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
ConfirmedSummary:
type: object
required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
required: [planId, containerBookingId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
loadingListNo: {type: string, description: 装柜清单号}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
@@ -0,0 +1,74 @@
openapi: 3.0.3
info: {title: Phase 3 Ocean Booking API, version: 1.1.0}
paths:
/api/platform/v1/ocean-bookings:
post:
summary: Create an ocean booking draft with immutable channel and warehouse snapshots
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}}
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
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}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Confirm released transport data and synchronize associated cabinet transport snapshots
description: Does not create cabinets, change channels, replace cabinetNo, or recreate loading plans.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
CreateRequest:
type: object
required: [expectedVersion, data]
properties:
expectedVersion: {type: integer, enum: [0]}
data: {$ref: '#/components/schemas/DraftData'}
DraftData:
type: object
required: [customerId, customerName, channelCode, channelName, warehouseId, warehouseName, parties, containerRequirements]
properties:
customerId: {type: integer, format: int64, minimum: 1}
customerName: {type: string, maxLength: 128}
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
warehouseId: {type: integer, format: int64, minimum: 1}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
estimatedSailingAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release}
estimatedArrivalAt: {type: string, format: date-time, nullable: true, deprecated: true, description: Must be omitted until release}
parties: {type: array, minItems: 3, items: {type: object}}
containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}}
ContainerRequirement:
type: object
required: [containerType, containerKind, capacityCbm, maxWeightKg, quantity]
properties:
containerType: {type: string, maxLength: 32}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}
quantity: {type: integer, minimum: 1}
ActionRequest:
type: object
required: [expectedVersion]
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000}}
ReleaseRequest:
type: object
required: [expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt]
properties:
expectedVersion: {type: integer, minimum: 0}
billOfLadingNo: {type: string, maxLength: 128}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
estimatedSailingAt: {type: string, format: date-time}
estimatedArrivalAt: {type: string, format: date-time}
reason: {type: string, maxLength: 1000}
@@ -7,7 +7,7 @@ paths:
get:
summary: Find the active container picking task in one platform warehouse
parameters:
- {name: containerNo, in: query, required: true, schema: {type: string}}
- {name: containerNo, in: query, required: true, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
@@ -26,6 +26,14 @@ paths:
schema:
type: array
items: {$ref: '#/components/schemas/PendingTask'}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}:
get:
summary: Read one PDA task by stable task identity
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200': {description: Task with current transport display fields}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim:
post:
summary: Atomically claim an unassigned task for the authenticated operator
@@ -112,11 +120,13 @@ components:
schemas:
Task:
type: object
required: [id, loadingPlanId, containerNo, taskStatus, workflowStatus, version, items]
required: [id, loadingPlanId, containerBookingId, cabinetNo, taskStatus, workflowStatus, version, items]
properties:
id: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerNo: {type: string}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
currentContainerNo: {type: string, nullable: true, description: Mutable transport display field; never task identity}
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
version: {type: integer, minimum: 0}
@@ -147,11 +157,13 @@ components:
version: {type: integer, minimum: 0}
PendingTask:
type: object
required: [taskId, loadingPlanId, containerNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount]
required: [taskId, loadingPlanId, containerBookingId, cabinetNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount]
properties:
taskId: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerNo: {type: string}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
currentContainerNo: {type: string, nullable: true}
warehouseId: {type: integer, format: int64}
warehouseName: {type: string, nullable: true}
taskStatus: {type: string, enum: [LOADING]}
@@ -176,10 +188,12 @@ components:
operationId: {type: string, maxLength: 128}
BatchRequest:
type: object
required: [warehouseId, containerNo, expectedVersion, inventorySnapshotToken, items]
required: [warehouseId, loadingPlanId, containerBookingId, expectedVersion, inventorySnapshotToken, items]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
containerNo: {type: string, maxLength: 64}
loadingPlanId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
containerNo: {type: string, maxLength: 64, nullable: true, deprecated: true, description: Accepted during compatibility period but ignored for identity and idempotency}
expectedVersion: {type: integer, minimum: 0}
inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64}
items:
@@ -217,11 +231,14 @@ components:
remark: {type: string, maxLength: 500}
InventorySnapshot:
type: object
required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds]
required: [taskId, loadingPlanId, containerBookingId, cabinetNo, taskVersion, warehouseId, capturedAt, snapshotToken, inbounds]
properties:
taskId: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
taskVersion: {type: integer, minimum: 0}
containerNo: {type: string}
currentContainerNo: {type: string, nullable: true}
warehouseId: {type: integer, format: int64}
capturedAt: {type: string, format: date-time}
snapshotToken: {type: string, minLength: 64, maxLength: 64}
@@ -102,7 +102,10 @@ class LoadingListControllerContractTest {
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount");
assertThat(properties(schemas, "PreloadSummary"))
.containsKeys("planId", "containerBookingId", "customers", "skuCount");
assertThat(properties(schemas, "ConfirmedSummary"))
.containsKeys("planId", "containerBookingId", "loadingListNo");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LoadingListRepositoryTest {
@Mock
private LoadingListQueryMapper mapper;
private LoadingListRepository repository;
@BeforeEach
void setUp() {
repository = new LoadingListRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
}
@Test
void mapsRealContainerBookingIdIntoPreloadAndConfirmedSummaries() {
AggregateRow row = new AggregateRow();
row.setPlanId(41L);
row.setContainerBookingId(61L);
row.setPlanNo("LP-41");
row.setPlanStatus("LOADING");
row.setVersion(1);
Page<AggregateRow> preloadPage = new Page<>(1, 20);
preloadPage.setRecords(List.of(row));
preloadPage.setTotal(1);
when(mapper.selectPreloadPage(any(), any())).thenReturn(preloadPage);
when(mapper.selectCustomers(List.of(41L))).thenReturn(List.of());
Page<AggregateRow> confirmedPage = new Page<>(1, 20);
confirmedPage.setRecords(List.of(row));
confirmedPage.setTotal(1);
when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage);
var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, 1, 20))
.records().get(0);
var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20))
.records().get(0);
assertThat(preload.planId()).isEqualTo(41L);
assertThat(preload.containerBookingId()).isEqualTo(61L);
assertThat(confirmed.planId()).isEqualTo(41L);
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
}
}