feat: close preload confirmation and loading flow
This commit is contained in:
+16
-1
@@ -9,6 +9,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
@@ -66,7 +68,7 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
|
||||
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。")
|
||||
@PatchMapping("/loading-plans/{planId}/transport")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadDetail> updateTransport(
|
||||
@@ -80,6 +82,19 @@ public class PlatformLoadingListController {
|
||||
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
|
||||
@PostMapping("/loading-plans/{planId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody ConfirmPreloadRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.confirmPreload(
|
||||
planId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
|
||||
@GetMapping("/loading-lists")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
|
||||
@@ -83,12 +83,43 @@ public final class LoadingListDtos {
|
||||
public record Customer(Long accountId, String customerName) {
|
||||
}
|
||||
|
||||
public record PreloadReadiness(
|
||||
boolean preloadPushed,
|
||||
boolean containerNoReady,
|
||||
int preloadLinkCount,
|
||||
int confirmedPreloadCount,
|
||||
Long oceanBookingId,
|
||||
String oceanBookingStatus,
|
||||
Long pdaTaskId,
|
||||
String pdaTaskStatus,
|
||||
String confirmationStatus,
|
||||
boolean canConfirm,
|
||||
boolean readyForPda,
|
||||
List<String> blockingReasons
|
||||
) {
|
||||
public PreloadReadiness {
|
||||
blockingReasons = blockingReasons == null ? List.of() : List.copyOf(blockingReasons);
|
||||
}
|
||||
}
|
||||
|
||||
public record ConfirmPreloadRequest(@NotNull @Min(0) Integer expectedVersion) {
|
||||
}
|
||||
|
||||
public record PreloadConfirmationResponse(
|
||||
Long planId,
|
||||
Long containerBookingId,
|
||||
String confirmationStatus,
|
||||
Integer version,
|
||||
PreloadReadiness readiness
|
||||
) {
|
||||
}
|
||||
|
||||
public record PreloadSummary(
|
||||
Long planId,
|
||||
Long containerBookingId,
|
||||
String preloadNo,
|
||||
String containerNo,
|
||||
String internalContainerNo,
|
||||
String cabinetNo,
|
||||
List<Customer> customers,
|
||||
Long warehouseId,
|
||||
String warehouseName,
|
||||
@@ -101,7 +132,8 @@ public final class LoadingListDtos {
|
||||
BigDecimal totalVolumeCbm,
|
||||
BigDecimal totalGrossWeightKg,
|
||||
LocalDateTime createdAt,
|
||||
Integer version
|
||||
Integer version,
|
||||
PreloadReadiness readiness
|
||||
) {
|
||||
public PreloadSummary {
|
||||
customers = customers == null ? List.of() : List.copyOf(customers);
|
||||
@@ -115,7 +147,7 @@ public final class LoadingListDtos {
|
||||
Long warehouseId,
|
||||
String warehouseName,
|
||||
String containerNo,
|
||||
String internalContainerNo,
|
||||
String cabinetNo,
|
||||
String status,
|
||||
long totalPackageCount,
|
||||
BigDecimal totalGrossWeightKg,
|
||||
|
||||
+7
-2
@@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo,"
|
||||
+ "p.plan_status status,p.version FROM fl_loading_plan p JOIN fl_container_booking b "
|
||||
+ "p.plan_status status,p.version,p.preload_pushed_at preloadPushedAt FROM fl_loading_plan p JOIN fl_container_booking b "
|
||||
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
|
||||
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@@ -74,6 +74,11 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
|
||||
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
|
||||
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
||||
String status, Integer version) {}
|
||||
String status, Integer version, java.time.LocalDateTime preloadPushedAt) {
|
||||
public LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
||||
String status, Integer version) {
|
||||
this(id, containerBookingId, containerNo, status, version, null);
|
||||
}
|
||||
}
|
||||
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
|
||||
}
|
||||
|
||||
+17
@@ -112,6 +112,23 @@ public class LoadingModelBridgeService {
|
||||
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
|
||||
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
|
||||
|
||||
public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) {
|
||||
var loadingPlan = repository.loadingPlan(loadingPlanId);
|
||||
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||
if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) {
|
||||
throw conflict("仅已推送且仍为EDITING的预装计划可以确认");
|
||||
}
|
||||
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
||||
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物");
|
||||
List<Link> links = repository.byLoadingPlan(loadingPlanId);
|
||||
boolean complete = scopes.stream().allMatch(scope -> links.stream().anyMatch(link ->
|
||||
Objects.equals(link.tenantId(), scope.tenantId())
|
||||
&& Objects.equals(link.inboundId(), scope.inboundId())
|
||||
&& link.allocatedPackageCount() != null && link.allocatedPackageCount() > 0));
|
||||
if (!complete) throw conflict("装柜计划的预装关联不完整");
|
||||
return links;
|
||||
}
|
||||
|
||||
public void requireStarted(long tenantId, long inboundId) {
|
||||
List<Link> links = requireLinks(tenantId, inboundId);
|
||||
if (links.stream().anyMatch(link -> link.allocatedPackageCount() == null || link.allocatedPackageCount() <= 0
|
||||
|
||||
@@ -21,9 +21,24 @@ import java.util.List;
|
||||
public interface LoadingListQueryMapper {
|
||||
String AGGREGATE_COLUMNS = """
|
||||
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.vessel_name, b.voyage_no, b.container_type,
|
||||
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
|
||||
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
|
||||
(SELECT COUNT(*) FROM fl_preload_loading_plan_link confirmed_link
|
||||
JOIN fl_preload_plan confirmed_preload ON confirmed_preload.id = confirmed_link.preload_plan_id
|
||||
WHERE confirmed_link.loading_plan_id = p.id
|
||||
AND confirmed_preload.status = 'CONFIRMED') AS confirmed_preload_count,
|
||||
(SELECT allocation.ocean_booking_id FROM fl_ocean_booking_container_allocation allocation
|
||||
WHERE allocation.container_booking_id = p.container_booking_id LIMIT 1) AS ocean_booking_id,
|
||||
(SELECT ocean.status FROM fl_ocean_booking_container_allocation allocation
|
||||
JOIN fl_ocean_booking ocean ON ocean.id = allocation.ocean_booking_id
|
||||
WHERE allocation.container_booking_id = p.container_booking_id LIMIT 1) AS ocean_booking_status,
|
||||
(SELECT pick_task.id FROM fl_loading_pick_task pick_task
|
||||
WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_id,
|
||||
(SELECT pick_task.task_status FROM fl_loading_pick_task pick_task
|
||||
WHERE pick_task.loading_plan_id = p.id LIMIT 1) AS pda_task_status,
|
||||
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
|
||||
COALESCE(SUM(i.package_count), 0) AS total_package_count,
|
||||
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
|
||||
@@ -34,7 +49,7 @@ public interface LoadingListQueryMapper {
|
||||
|
||||
String AGGREGATE_GROUP = """
|
||||
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.vessel_name, b.voyage_no, b.container_type
|
||||
""";
|
||||
@@ -202,7 +217,8 @@ public interface LoadingListQueryMapper {
|
||||
UPDATE fl_loading_plan
|
||||
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{planId} AND plan_status = 'LOADING'
|
||||
WHERE id = #{planId} AND plan_status IN ('EDITING', 'LOADING')
|
||||
AND (plan_status = 'LOADING' OR preload_pushed_at IS NOT NULL)
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
|
||||
@@ -214,7 +230,8 @@ public interface LoadingListQueryMapper {
|
||||
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
|
||||
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
|
||||
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
|
||||
WHERE p.id = #{planId} AND p.plan_status = 'LOADING'
|
||||
WHERE p.id = #{planId} AND p.plan_status IN ('EDITING', 'LOADING')
|
||||
AND (p.plan_status = 'LOADING' OR p.preload_pushed_at IS NOT NULL)
|
||||
AND (#{containerNo} IS NOT NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_task t WHERE t.loading_plan_id = p.id))
|
||||
""")
|
||||
@@ -222,6 +239,30 @@ public interface LoadingListQueryMapper {
|
||||
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_preload_plan
|
||||
SET status = 'CONFIRMED', updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{preloadPlanId} AND status = #{fromStatus} AND version = #{expectedVersion}
|
||||
""")
|
||||
int confirmLinkedPreload(@Param("preloadPlanId") long preloadPlanId,
|
||||
@Param("fromStatus") String fromStatus,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan
|
||||
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int touchForPreloadConfirmation(@Param("planId") long planId,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan
|
||||
SET plan_status = #{toStatus},
|
||||
@@ -268,6 +309,7 @@ public interface LoadingListQueryMapper {
|
||||
private String createdByName;
|
||||
private LocalDateTime createdAt;
|
||||
private Integer version;
|
||||
private LocalDateTime preloadPushedAt;
|
||||
private String containerNo;
|
||||
private String cabinetNo;
|
||||
private Long warehouseId;
|
||||
@@ -275,6 +317,12 @@ public interface LoadingListQueryMapper {
|
||||
private String vesselName;
|
||||
private String voyageNo;
|
||||
private String containerType;
|
||||
private Long preloadLinkCount;
|
||||
private Long confirmedPreloadCount;
|
||||
private Long oceanBookingId;
|
||||
private String oceanBookingStatus;
|
||||
private Long pdaTaskId;
|
||||
private String pdaTaskStatus;
|
||||
private Long skuCount;
|
||||
private Long totalPackageCount;
|
||||
private BigDecimal totalGrossWeightKg;
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.Item;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
@@ -85,6 +86,17 @@ public class LoadingListRepository {
|
||||
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int confirmLinkedPreload(long preloadPlanId, String fromStatus, int expectedVersion,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.confirmLinkedPreload(preloadPlanId, fromStatus, expectedVersion,
|
||||
operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int touchForPreloadConfirmation(long planId, int expectedVersion,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.touchForPreloadConfirmation(planId, expectedVersion, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int advance(long planId, String fromStatus, String toStatus, int expectedVersion,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName);
|
||||
@@ -116,7 +128,36 @@ public class LoadingListRepository {
|
||||
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
|
||||
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
|
||||
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
|
||||
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion());
|
||||
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion(), readiness(row));
|
||||
}
|
||||
|
||||
private PreloadReadiness readiness(AggregateRow row) {
|
||||
boolean pushed = row.getPreloadPushedAt() != null;
|
||||
boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo());
|
||||
int linkCount = Math.toIntExact(value(row.getPreloadLinkCount()));
|
||||
int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount()));
|
||||
boolean oceanLinked = row.getOceanBookingId() != null;
|
||||
boolean released = "RELEASED".equals(row.getOceanBookingStatus());
|
||||
boolean editing = "EDITING".equals(row.getPlanStatus());
|
||||
boolean taskCreated = row.getPdaTaskId() != null;
|
||||
java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
|
||||
if (!pushed) blockers.add("PRELOAD_NOT_PUSHED");
|
||||
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
|
||||
if (!containerReady) blockers.add("CONTAINER_NO_MISSING");
|
||||
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
|
||||
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
|
||||
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
|
||||
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
|
||||
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
|
||||
boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released;
|
||||
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
|
||||
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
|
||||
String confirmationStatus = allConfirmed ? "CONFIRMED"
|
||||
: confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING";
|
||||
return new PreloadReadiness(pushed, containerReady, linkCount, confirmedCount,
|
||||
row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(),
|
||||
row.getPdaTaskStatus(), confirmationStatus, canConfirm,
|
||||
prerequisitesReady && allConfirmed, blockers);
|
||||
}
|
||||
|
||||
private ConfirmedSummary confirmed(AggregateRow row) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
|
||||
@@ -18,6 +20,7 @@ import com.baiyue.firstleg.client.LoadingDocumentFileClient;
|
||||
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -28,6 +31,7 @@ import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -38,6 +42,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -57,6 +62,9 @@ public class LoadingListService {
|
||||
@Resource
|
||||
private LoadingPickRepository pickRepository;
|
||||
@Resource
|
||||
@Lazy
|
||||
private LoadingPickService loadingPickService;
|
||||
@Resource
|
||||
private LoadingCompletedOutboxService loadingCompletedOutboxService;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@@ -420,9 +428,51 @@ public class LoadingListService {
|
||||
"containerNo", containerNo, "voyageNo", voyageNo,
|
||||
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
|
||||
trim(request.operationReason()));
|
||||
loadingPickService.beginIfReady(before.getContainerBookingId(), operatorId, operatorName);
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PRELOAD_PLAN_CONFIRM", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "原子确认装柜计划全部预装票据")
|
||||
public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName,
|
||||
String idempotencyKey,
|
||||
ConfirmPreloadRequest request) {
|
||||
String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion());
|
||||
String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey);
|
||||
HistoryRow replay = repository.history(operationId);
|
||||
if (replay != null) return confirmationReplay(replay, requestHash);
|
||||
|
||||
AggregateRow before = requirePlan(planId);
|
||||
List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId);
|
||||
if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(),
|
||||
operatorId, trim(operatorName)) != 1) {
|
||||
throw conflict("预装计划已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
Map<Long, Link> preloads = new LinkedHashMap<>();
|
||||
links.forEach(link -> preloads.putIfAbsent(link.preloadPlanId(), link));
|
||||
for (Link link : preloads.values()) {
|
||||
if (repository.confirmLinkedPreload(link.preloadPlanId(), link.preloadStatus(),
|
||||
link.preloadVersion(), operatorId, trim(operatorName)) != 1) {
|
||||
throw conflict("关联预装票据已被其他操作修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
links.stream().map(Link::containerBookingId).filter(java.util.Objects::nonNull).distinct()
|
||||
.forEach(containerBookingId -> loadingPickService.beginIfReady(
|
||||
containerBookingId, operatorId, operatorName));
|
||||
|
||||
AggregateRow after = requirePlan(planId);
|
||||
PreloadDetail detail = repository.preloadDetail(after);
|
||||
PreloadConfirmationResponse response = new PreloadConfirmationResponse(planId,
|
||||
after.getContainerBookingId(), detail.summary().readiness().confirmationStatus(),
|
||||
after.getVersion(), detail.summary().readiness());
|
||||
repository.appendHistory(planId, operationId, "CONFIRM_PRELOAD",
|
||||
before.getPlanStatus(), after.getPlanStatus(), json(snapshot(before)),
|
||||
json(values("requestHash", requestHash, "response", response)),
|
||||
operatorId, trim(operatorName), null);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
|
||||
*/
|
||||
@@ -481,6 +531,9 @@ public class LoadingListService {
|
||||
if (!PRELOAD_STATUSES.contains(row.getPlanStatus())) {
|
||||
throw conflict("仅预装柜中或拣货中可修改集装箱号与航次");
|
||||
}
|
||||
if ("EDITING".equals(row.getPlanStatus()) && row.getPreloadPushedAt() == null) {
|
||||
throw conflict("仅已推送的预装清单可维护运输字段");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
|
||||
@@ -507,6 +560,17 @@ public class LoadingListService {
|
||||
}
|
||||
}
|
||||
|
||||
private PreloadConfirmationResponse confirmationReplay(HistoryRow replay, String requestHash) {
|
||||
requireSameRequest(replay, requestHash);
|
||||
try {
|
||||
var response = objectMapper.readTree(replay.getAfterSnapshot()).get("response");
|
||||
if (response == null) throw new IllegalStateException("预装确认幂等响应缺失");
|
||||
return objectMapper.treeToValue(response, PreloadConfirmationResponse.class);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("无法读取预装确认幂等响应", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> snapshot(AggregateRow row) {
|
||||
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
|
||||
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
|
||||
|
||||
@@ -32,6 +32,8 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
@@ -71,6 +73,8 @@ public class LoadingPickService {
|
||||
@Resource
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@Resource
|
||||
private LoadingModelBridgeService loadingModelBridge;
|
||||
@Resource
|
||||
private LoadingListService loadingListService;
|
||||
@Resource
|
||||
private InboundStatusHistoryMapper inboundHistories;
|
||||
@@ -88,6 +92,8 @@ public class LoadingPickService {
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
|
||||
var plan = smartLoadingRepository.planByBooking(containerBookingId);
|
||||
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
||||
var links = loadingModelBridge.byLoadingPlan(plan.getId());
|
||||
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
|
||||
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
|
||||
if (allocation == null) return;
|
||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||
@@ -97,6 +103,29 @@ public class LoadingPickService {
|
||||
smartLoadingRepository.items(plan.getId()));
|
||||
}
|
||||
|
||||
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
||||
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
||||
for (Link link : links) {
|
||||
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
|
||||
throw conflict("预装清单已确认或当前状态不允许确认");
|
||||
}
|
||||
var booking = smartLoadingRepository.booking(link.containerBookingId());
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("确认预装前必须录入真实集装箱号");
|
||||
}
|
||||
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
||||
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||
if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) {
|
||||
throw conflict("确认预装前关联订舱必须已放舱");
|
||||
}
|
||||
if (repository.taskByPlan(link.loadingPlanId()) != null) {
|
||||
throw conflict("PDA任务已创建,不能重复确认预装");
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||
List<LoadingPlanItemEntity> planItems) {
|
||||
|
||||
@@ -236,9 +236,6 @@ public class SmartLoadingService {
|
||||
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
|
||||
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
if (!StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
||||
}
|
||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||
requirePushReplay(planId, key, requestHash);
|
||||
return view(plan, booking);
|
||||
@@ -261,7 +258,6 @@ public class SmartLoadingService {
|
||||
}
|
||||
LoadingPlanEntity pushed = repository.plan(planId);
|
||||
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
||||
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
|
||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||
}
|
||||
|
||||
|
||||
-27
@@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
|
||||
@@ -44,32 +43,6 @@ public class PlatformSplitPreloadController {
|
||||
return ApiResponse.success(service.smartSplit(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "提交预装清单确认")
|
||||
@PostMapping("/{inboundId}/preload/confirming")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
|
||||
@BusinessAudit(event = "INBOUND_PRELOAD_CONFIRMING", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "提交预装清单确认")
|
||||
public ApiResponse<SplitActionResponse> confirming(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ConfirmPreloadRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.startConfirming(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认预装清单并进入装柜中")
|
||||
@PostMapping("/{inboundId}/preload/confirm")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload')")
|
||||
@BusinessAudit(event = "INBOUND_CONFIRM_PRELOAD", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "确认预装清单")
|
||||
public ApiResponse<SplitActionResponse> confirm(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ConfirmPreloadRequest request) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.confirmPreload(tenantId, inboundId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询预装清单")
|
||||
@GetMapping("/{inboundId}/preload")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:preload-view')")
|
||||
|
||||
@@ -18,8 +18,6 @@ public final class SplitRequests {
|
||||
List<@Valid PreloadItem> items,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record ConfirmPreloadRequest(@NotNull @PositiveOrZero Integer expectedVersion) {}
|
||||
|
||||
public record PreloadItem(
|
||||
@Size(max = 64) String containerCode,
|
||||
@Size(max = 128) String cargoReference,
|
||||
|
||||
@@ -16,12 +16,4 @@ public interface PreloadPlanMapper extends BaseMapper<PreloadPlanEntity> {
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("UPDATE fl_preload_plan SET status=#{toStatus}, updated_by=#{operatorId}, "
|
||||
+ "updated_by_name=#{operatorName}, version=version+1 "
|
||||
+ "WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status=#{fromStatus} "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
int changeStatus(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.baiyue.firstleg.split.mapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface SplitInboundMapper {
|
||||
@@ -11,11 +10,5 @@ public interface SplitInboundMapper {
|
||||
+ "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||
InboundSnapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
|
||||
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
|
||||
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
|
||||
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
record InboundSnapshot(Long id, Long tenantId, String processStatus, Integer version) {}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,6 @@ public class SplitRepository {
|
||||
return inbounds.find(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public int beginLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public PreloadPlanEntity findPlan(long tenantId, long inboundId) {
|
||||
return plans.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||
.eq(PreloadPlanEntity::getTenantId, tenantId)
|
||||
@@ -39,12 +35,6 @@ public class SplitRepository {
|
||||
return plans.replace(tenantId, inboundId, itemsJson, remark, operatorId, operatorName, expectedVersion);
|
||||
}
|
||||
|
||||
public int changePlanStatus(long tenantId, long inboundId, String fromStatus, String toStatus,
|
||||
long operatorId, String operatorName, int expectedVersion) {
|
||||
return plans.changeStatus(tenantId, inboundId, fromStatus, toStatus,
|
||||
operatorId, operatorName, expectedVersion);
|
||||
}
|
||||
|
||||
public SplitOperationEntity findOperation(long tenantId, long inboundId, long operatorId,
|
||||
String operation, String key) {
|
||||
return operations.selectOne(new LambdaQueryWrapper<SplitOperationEntity>()
|
||||
|
||||
@@ -2,11 +2,7 @@ package com.baiyue.firstleg.split.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.split.domain.PreloadStatus;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
|
||||
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
|
||||
import com.baiyue.firstleg.split.dto.SplitResponses.PreloadPlanResponse;
|
||||
@@ -24,19 +20,14 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
|
||||
@Service
|
||||
public class SplitPreloadService {
|
||||
private static final String SMART_SPLIT = "SMART_SPLIT";
|
||||
private static final String START_CONFIRMING = "START_PRELOAD_CONFIRMING";
|
||||
private static final String CONFIRM_PRELOAD = "CONFIRM_PRELOAD";
|
||||
|
||||
@Resource private SplitRepository repository;
|
||||
@Resource private LoadingModelBridgeService loadingModelBridge;
|
||||
@Resource private InboundStatusHistoryMapper histories;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
@@ -101,67 +92,6 @@ public class SplitPreloadService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key,
|
||||
ConfirmPreloadRequest request) {
|
||||
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
|
||||
if (replay != null) return replay;
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
boolean loading = "LOADING".equals(inbound.processStatus());
|
||||
if (loading) {
|
||||
// Starting a real loading task advances the inbound before its preload
|
||||
// confirmation is submitted; retain the linked-container hard gate.
|
||||
loadingModelBridge.requireStarted(tenantId, inboundId);
|
||||
} else {
|
||||
requireWaitingSplit(inbound, "仅待分柜订单可以提交预装清单确认");
|
||||
}
|
||||
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
|
||||
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
|
||||
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
|
||||
new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT", response(plan)));
|
||||
}
|
||||
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|
||||
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
|
||||
PreloadStatus.CONFIRMING, operatorId, operatorName, request.expectedVersion()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
|
||||
new SplitActionResponse(loading ? "LOADING" : "WAIT_SPLIT",
|
||||
response(repository.findPlan(tenantId, inboundId))));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SplitActionResponse confirmPreload(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, String key,
|
||||
ConfirmPreloadRequest request) {
|
||||
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key);
|
||||
if (replay != null) return replay;
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if ("LOADING".equals(inbound.processStatus())) {
|
||||
loadingModelBridge.requireStarted(tenantId, inboundId);
|
||||
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
|
||||
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
|
||||
}
|
||||
requireWaitingSplit(inbound, "仅待分柜订单可以确认预装清单");
|
||||
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
|
||||
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
|
||||
}
|
||||
loadingModelBridge.requireStarted(tenantId, inboundId);
|
||||
if (repository.changePlanStatus(tenantId, inboundId, plan.getStatus(), PreloadStatus.CONFIRMED,
|
||||
operatorId, operatorName, request.expectedVersion()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
addHistory(tenantId, inboundId, inbound.processStatus(), "LOADING", CONFIRM_PRELOAD,
|
||||
operatorId, operatorName);
|
||||
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
|
||||
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
|
||||
}
|
||||
|
||||
public PreloadPlanResponse detail(long tenantId, long inboundId) {
|
||||
requireInbound(tenantId, inboundId);
|
||||
return response(requirePlan(tenantId, inboundId));
|
||||
@@ -209,19 +139,6 @@ public class SplitPreloadService {
|
||||
return "WAIT_SPLIT".equals(status);
|
||||
}
|
||||
|
||||
private void addHistory(long tenantId, long inboundId, String from, String to, String action,
|
||||
long operatorId, String operatorName) {
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setTenantId(tenantId);
|
||||
history.setInboundId(inboundId);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setAction(action);
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
private PreloadPlanResponse response(PreloadPlanEntity plan) {
|
||||
if (plan == null) return null;
|
||||
return new PreloadPlanResponse(plan.getId(), plan.getInboundId(), plan.getTenantId(),
|
||||
|
||||
Reference in New Issue
Block a user