第三期装柜验收优化修改

This commit is contained in:
2026-08-12 13:34:26 +08:00
parent d2704f68a6
commit c2b1ab92ff
27 changed files with 258 additions and 71 deletions
@@ -5166,7 +5166,7 @@ BEGIN
AND pick_task.loading_plan_id = OLD.id AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND (pick_task.id IS NULL AND (pick_task.id IS NULL
OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN OR pick_item.picked_package_count <> item.package_count)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
END IF; END IF;
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
@@ -9334,7 +9334,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_operation (
UNIQUE KEY uk_loading_pick_operation_id (operation_id), UNIQUE KEY uk_loading_pick_operation_id (operation_id),
KEY idx_loading_pick_operation_task (pick_task_id, created_at), KEY idx_loading_pick_operation_task (pick_task_id, created_at),
CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH')) CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH', 'COMPLETE_LOADING'))
) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; ) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计';
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update;
@@ -9546,7 +9546,7 @@ BEGIN
END IF; END IF;
ALTER TABLE fl_loading_pick_operation ALTER TABLE fl_loading_pick_operation
ADD CONSTRAINT chk_loading_pick_operation_action ADD CONSTRAINT chk_loading_pick_operation_action
CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH')); CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH', 'COMPLETE_LOADING'));
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_pda_replace_operation_check(); CALL phase3_pda_replace_operation_check();
@@ -12296,7 +12296,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment',
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'pick_item_id', 'PDA拣货明细ID,同时作为本表主键'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'pick_item_id', 'PDA拣货明细ID,同时作为本表主键');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'updated_at', '明细分配记录最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'updated_at', '明细分配记录最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'version', '乐观锁版本号,每次更新递增'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'version', '乐观锁版本号,每次更新递增');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'action_type', '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'action_type', '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交、COMPLETE_LOADING人工确认装柜完成');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'created_at', '不可变操作审计创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'created_at', '不可变操作审计创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'id', 'PDA任务操作审计记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'id', 'PDA任务操作审计记录ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'operation_id', '全局唯一操作ID,用于幂等重放'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'operation_id', '全局唯一操作ID,用于幂等重放');
@@ -38,7 +38,12 @@ public interface WarehousePalletLocationClient {
@PostMapping("/settlements") @PostMapping("/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request); @RequestBody SettlementRequest request);
@GetMapping("/settlements/target")
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("sourceBusinessId") long sourceBusinessId,
@RequestParam("idempotencyKey") String idempotencyKey);
@GetMapping("/occupied-resources") @GetMapping("/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources( ApiResponse<List<OccupiedResource>> occupiedResources(
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
@@ -137,6 +138,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.review(taskId, request, user.userId(), user.username())); return ApiResponse.success(service.review(taskId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/complete")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody CompleteLoadingRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.complete(taskId, request, user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/operations") @GetMapping("/tasks/{taskId}/operations")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user,
@@ -8,7 +8,7 @@ public enum SmartLoadingStatus {
EDITING, EDITING,
/** 拣货中:已生成拣货任务,仍可追加或调整未拣货明细。 */ /** 拣货中:已生成拣货任务,仍可追加或调整未拣货明细。 */
LOADING, LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */ /** 装柜完成:PDA 人工确认装柜完成后进入,计划、明细和货柜信息只读。 */
COMPLETED; COMPLETED;
/** 装柜完成前可维护集装箱号和航次。 */ /** 装柜完成前可维护集装箱号和航次。 */
@@ -98,6 +98,15 @@ public final class LoadingPickDtos {
) { ) {
} }
/** PDA 人工确认装柜完成。拣货记录只会锁定货物,确认后才结算资源并完成装柜。 */
public record CompleteLoadingRequest(
@NotNull @Min(0) Integer expectedTaskVersion,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String remark
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId, public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId,
String orderNo, String inboundCode, String cargoName, List<String> boxCodes, String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
Integer plannedPackageCount, Integer pickedPackageCount, Integer plannedPackageCount, Integer pickedPackageCount,
@@ -182,8 +182,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0) AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING') AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count &lt; #{finalCount} AND picked_package_count < #{finalCount}
AND #{finalCount} &lt; planned_package_count AND #{finalCount} < planned_package_count
""") """)
int partial(@Param("id") long id, @Param("finalCount") int finalCount, int partial(@Param("id") long id, @Param("finalCount") int finalCount,
@Param("releaseMode") String releaseMode, @Param("releaseMode") String releaseMode,
@@ -12,7 +12,9 @@ import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
@@ -25,6 +27,8 @@ import java.time.LocalDateTime;
public class ContainerService { public class ContainerService {
@Resource private ContainerRepository repository; @Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses; @Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickService loadingPickService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) { public PageResult<ContainerBookingEntity> page(Query query) {
@@ -83,7 +87,19 @@ public class ContainerService {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(), if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); } } catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
return required(id); ContainerBookingEntity updated = required(id);
beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName);
return updated;
}
/** 预装清单先推送、后录入集装箱号时,补建对应的 PDA 拣货任务。 */
private void beginPdaTaskAfterContainerBound(ContainerBookingEntity booking, String containerNo,
long operatorId, String operatorName) {
if (containerNo == null) return;
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId());
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName,
smartLoadingRepository.items(plan.getId()));
} }
private ContainerBookingEntity required(long id) { private ContainerBookingEntity required(long id) {
@@ -107,11 +107,11 @@ public class LoadingListService {
} }
/** /**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。 * PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/ */
@Transactional @Transactional
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN", @BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单") resourceId = "#planId", description = "PDA确认装柜完成后生成装柜清单")
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) { public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId; String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) { if (repository.history(operationId) != null) {
@@ -139,7 +139,7 @@ public class LoadingListService {
"LOADING", "COMPLETED", json(snapshot(before)), "LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED", json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName), "version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货"); "PDA已人工确认装柜完成");
} }
private AggregateRow requirePlan(long planId) { private AggregateRow requirePlan(long planId) {
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.Review;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
@@ -327,39 +328,37 @@ public class LoadingPickService {
@Transactional @Transactional
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) { public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(),
request.remark()), operatorId, operatorName);
}
/**
* PDA 人工点击装柜完成后的唯一结算入口。
* 逐项拣货仅记录和锁定库存;此处才执行拆单、资源释放、计划完成和装柜清单生成。
*/
@Transactional
public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId()); String operationId = operation(request.operationId());
String remark = trim(request.remark()); String remark = trim(request.remark());
String requestHash = hash("REVIEW|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark); String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay; if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId); LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
if (!request.expectedTaskVersion().equals(task.getVersion())) { if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核"); throw conflict("PDA任务版本已变化,请刷新后复核");
} }
if (repository.review(taskId) != null) throw conflict("PDA任务已经复核"); if (!repository.allTaskItemsResolved(taskId)) {
List<LoadingPickItemEntity> taskItems = repository.items(taskId); throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (List<PdaPickSettlementPlan.SplitLine> lines : resolution.partialByInbound().values()) {
for (PdaPickSettlementPlan.SplitLine line : lines) {
if (repository.settlePartial(line.loadingPlanItemId(), line.pickItemId(),
line.pickedPackageCount(), line.pickedPackageCount() + line.remainingPackageCount(),
operatorId, operatorName) != 1) {
throw conflict("部分拣货结算版本已变化");
}
}
}
for (Long planItemId : resolution.skippedPlanItemIds()) {
if (repository.settleSkipped(planItemId, operatorId, operatorName) != 1) {
throw conflict("跳拣结算版本已变化");
}
} }
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
settlementService.settle(taskId, operatorId, operatorName);
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数"); throw conflict("柜次仍有未完成PDA拣货的箱数");
} }
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) { if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
throw conflict("PDA任务已经复核"); throw conflict("PDA任务已经复核");
} }
insertOperation(taskId, operationId, requestHash, "REVIEW", operatorId, operatorName); insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
repository.syncInboundProjections(taskId, operatorId, operatorName); repository.syncInboundProjections(taskId, operatorId, operatorName);
return view(requiredTask(taskId), null); return view(requiredTask(taskId), null);
@@ -408,13 +407,7 @@ public class LoadingPickService {
} }
private void settleAndFinish(LoadingPickTaskEntity task, long operatorId, String operatorName) { private void settleAndFinish(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (!repository.allTaskItemsResolved(task.getId())) { // 拣货完成仅锁定明细。资源结算与装柜完成必须由 PDA 人工点击“装柜完成”触发。
return;
}
settlementService.settle(task.getId(), operatorId, operatorName);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName);
}
} }
/** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */ /** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */
@@ -736,8 +729,7 @@ public class LoadingPickService {
} }
/** /**
* 先触碰仍处于 LOADING 的任务,再结算并推进装柜计划。计划完成触发器会同步把任务改为 * 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。
* COMPLETED,因此绝不能在 finishFromPda 之后再以 LOADING 状态触碰任务。
*/ */
private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) { private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) { if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
@@ -161,7 +161,11 @@ public class PdaPickSettlementService {
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines, OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId, pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"pda-task-" + taskId, operatorId, operatorName); "pda-task-" + taskId, operatorId, operatorName);
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L; String settlementKey = "PDA-SETTLE-" + taskId + '-' + parentInboundId;
long remainderChildId = childPackageCount > 0 ? existingSettlementTarget(parentInboundId, settlementKey) : 0L;
if (childPackageCount > 0 && remainderChildId == 0L) {
remainderChildId = IdWorker.getId();
}
long batchId = IdWorker.getId(); long batchId = IdWorker.getId();
String idempotencyKey = "pda-task-" + taskId; String idempotencyKey = "pda-task-" + taskId;
int rootVersion = root.version() == null ? 0 : root.version(); int rootVersion = root.version() == null ? 0 : root.version();
@@ -421,6 +425,16 @@ public class PdaPickSettlementService {
} }
} }
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
try {
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
return response != null && response.successful() && response.data() != null
? response.data() : 0L;
} catch (FeignException exception) {
return 0L;
}
}
private static boolean sameReleasedReservations(List<Long> expected, private static boolean sameReleasedReservations(List<Long> expected,
List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) { List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) {
return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId) return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId)
@@ -61,7 +61,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_operation (
UNIQUE KEY uk_loading_pick_operation_id (operation_id), UNIQUE KEY uk_loading_pick_operation_id (operation_id),
KEY idx_loading_pick_operation_task (pick_task_id, created_at), KEY idx_loading_pick_operation_task (pick_task_id, created_at),
CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), CONSTRAINT fk_loading_pick_operation_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW', 'BATCH')) CONSTRAINT chk_loading_pick_operation_action CHECK (action_type IN ('CLAIM', 'ASSIGN', 'REVIEW', 'BATCH', 'COMPLETE_LOADING'))
) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计'; ) ENGINE=InnoDB COMMENT='柜次PDA任务级幂等审计';
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update; DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update;
@@ -653,7 +653,7 @@ BEGIN
AND pick_task.loading_plan_id = OLD.id AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND (pick_task.id IS NULL AND (pick_task.id IS NULL
OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN OR pick_item.picked_package_count <> item.package_count)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
END IF; END IF;
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
@@ -82,13 +82,23 @@ paths:
'200': {description: Completed task, identical replay result, or A0409 when any snapshot changed} '200': {description: Completed task, identical replay result, or A0409 when any snapshot changed}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review:
post: post:
summary: Review all resolved items, settle partial and skipped quantities, then complete the plan summary: 人工确认装柜完成的兼容入口
parameters: [{$ref: '#/components/parameters/TaskId'}] parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: requestBody:
required: true required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ReviewRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/ReviewRequest'}}}
responses: responses:
'200': {description: Completed task or A0409 when items or task version changed} '200': {description: 操作员明确确认后完成装柜}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
post:
summary: PDA拣货全部处理后人工确认装柜完成
description: PDA拣货只锁定数量和仓储资源,本接口执行结算、释放资源并生成装柜清单。
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}}
responses:
'200': {description: 完成任务;存在未处理明细或版本冲突时返回A0409}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
get: get:
summary: Read immutable task-level claim, assignment and review audit records summary: Read immutable task-level claim, assignment and review audit records
@@ -198,6 +208,13 @@ components:
expectedTaskVersion: {type: integer, minimum: 0} expectedTaskVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128} operationId: {type: string, maxLength: 128}
remark: {type: string, maxLength: 500} remark: {type: string, maxLength: 500}
CompleteLoadingRequest:
type: object
required: [expectedTaskVersion, operationId]
properties:
expectedTaskVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
remark: {type: string, maxLength: 500}
InventorySnapshot: InventorySnapshot:
type: object type: object
required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds] required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds]
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PdaConvergenceOpenApiContractTest { class PdaConvergenceOpenApiContractTest {
@Test @Test
void publishesClaimAssignWorkAndReviewRoutes() throws Exception { void publishesClaimAssignWorkAndManualCompletionRoutes() throws Exception {
String yaml = new ClassPathResource("openapi/phase3-pda-convergence-v1.yaml") String yaml = new ClassPathResource("openapi/phase3-pda-convergence-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml) assertThat(yaml)
@@ -22,11 +22,13 @@ class PdaConvergenceOpenApiContractTest {
.contains("/tasks/{taskId}/items/{itemId}/skip:") .contains("/tasks/{taskId}/items/{itemId}/skip:")
.contains("/tasks/{taskId}/batch:") .contains("/tasks/{taskId}/batch:")
.contains("/tasks/{taskId}/review:") .contains("/tasks/{taskId}/review:")
.contains("/tasks/{taskId}/complete:")
.contains("/tasks/{taskId}/operations:") .contains("/tasks/{taskId}/operations:")
.contains("expectedAssignmentVersion") .contains("expectedAssignmentVersion")
.contains("BatchRequest") .contains("BatchRequest")
.contains("inventorySnapshotToken") .contains("inventorySnapshotToken")
.contains("InventorySnapshot") .contains("InventorySnapshot")
.contains("expectedTaskVersion"); .contains("expectedTaskVersion")
.contains("CompleteLoadingRequest");
} }
} }
@@ -22,6 +22,7 @@ class PlatformLoadingPdaControllerContractTest {
authority("skip", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, SkipRequest.class); authority("skip", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, SkipRequest.class);
authority("batch", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, String.class, BatchRequest.class); authority("batch", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, String.class, BatchRequest.class);
authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class);
authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class);
authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class);
} }
@@ -41,7 +41,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("trg_inbound_split_detail_before_insert") .contains("trg_inbound_split_detail_before_insert")
.contains("trg_inbound_split_detail_before_update") .contains("trg_inbound_split_detail_before_update")
.contains("trg_inbound_split_detail_before_delete") .contains("trg_inbound_split_detail_before_delete")
.contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH'))") .contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH', 'COMPLETE_LOADING'))")
.contains("phase3_loading_evidence_add_column") .contains("phase3_loading_evidence_add_column")
.contains("DROP INDEX uk_loading_evidence_type") .contains("DROP INDEX uk_loading_evidence_type")
.contains("DROP INDEX uk_loading_evidence_seal") .contains("DROP INDEX uk_loading_evidence_seal")
@@ -116,7 +116,7 @@ class SmartLoadingSqlContractTest {
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("LEFT JOIN fl_loading_pick_item pick_item") .contains("LEFT JOIN fl_loading_pick_item pick_item")
.contains("pick_item.picked_package_count <> pick_item.planned_package_count") .contains("pick_item.picked_package_count <> item.package_count")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
@@ -7,7 +7,9 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -33,6 +35,8 @@ import static org.mockito.Mockito.when;
class ContainerServiceTest { class ContainerServiceTest {
@MockBean private ContainerRepository repository; @MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses; @MockBean private WarehouseMasterDataClient warehouses;
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickService loadingPickService;
@Resource private ContainerService service; @Resource private ContainerService service;
@BeforeEach @BeforeEach
@@ -118,6 +122,27 @@ class ContainerServiceTest {
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"); .updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
} }
@Test
@DisplayName("预装清单推送后再绑定集装箱号会创建PDA拣货任务")
void startsPdaTaskWhenContainerIsBoundAfterPreloadPush() {
ContainerBookingEntity current = booking("EDITING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(LocalDateTime.of(2026, 8, 11, 20, 0));
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
verify(loadingPickService).begin(org.mockito.ArgumentMatchers.eq(31L),
org.mockito.ArgumentMatchers.eq("MSCU1234567"), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq("admin"), any());
}
@Test @Test
@DisplayName("已确认货柜全部只读") @DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() { void rejectsConfirmedUpdate() {
@@ -109,7 +109,7 @@ class LoadingListServiceTest {
verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"); verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货")); eq(9L), eq("operator"), eq("PDA已人工确认装柜完成"));
} }
@Test @Test
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
@@ -67,7 +68,7 @@ class LoadingPickServiceTest {
} }
@Test @Test
void touchesTaskBeforeCompletingLastPickedItem() { void keepsResourcesLockedAfterLastPickedItemUntilManualCompletion() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
preparePick(task, item, "pick-2"); preparePick(task, item, "pick-2");
@@ -76,11 +77,10 @@ class LoadingPickServiceTest {
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator"); service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
InOrder order = inOrder(repository, settlementService, loadingListService); verify(repository).touchTask(20L, 0);
order.verify(repository).touchTask(20L, 0); verify(settlementService, never()).settle(20L, 9L, "operator");
order.verify(settlementService).settle(20L, 9L, "operator"); verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
order.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator");
order.verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class)); verify(repository).insert(any(LoadingPickHistoryEntity.class));
} }
@@ -131,7 +131,7 @@ class LoadingPickServiceTest {
} }
@Test @Test
void touchesTaskBeforeCompletingLastPartialItem() { void keepsPartialPickLockedUntilManualCompletion() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
preparePartial(task, item, "partial-1"); preparePartial(task, item, "partial-1");
@@ -141,11 +141,10 @@ class LoadingPickServiceTest {
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"),
9L, "operator"); 9L, "operator");
InOrder order = inOrder(repository, settlementService, loadingListService); verify(repository).touchTask(20L, 0);
order.verify(repository).touchTask(20L, 0); verify(settlementService, never()).settle(20L, 9L, "operator");
order.verify(settlementService).settle(20L, 9L, "operator"); verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
order.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator");
order.verify(repository).syncInboundProjections(20L, 9L, "operator");
} }
@Test @Test
@@ -171,7 +170,7 @@ class LoadingPickServiceTest {
} }
@Test @Test
void reviewSettlesResolvedItemsAndCompletesPlan() { void manualCompletionSettlesResolvedItemsAndCompletesPlan() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item(); LoadingPickItemEntity item = item();
item.setPlannedPackageCount(5); item.setPlannedPackageCount(5);
@@ -181,15 +180,15 @@ class LoadingPickServiceTest {
when(repository.task(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item)); when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item)); when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.settlePartial(40L, 30L, 3, 5, 9L, "operator")).thenReturn(1); when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
service.review(20L, new ReviewRequest(0, "review-1", "checked"), 9L, "operator"); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
verify(repository).insert(any(LoadingPickOperationEntity.class)); verify(repository).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService, never()).settle(20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator");
} }
@@ -237,8 +236,8 @@ class LoadingPickServiceTest {
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L); verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).skip(31L, 0, 101L); verify(repository, times(1)).skip(31L, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0); verify(repository, times(1)).touchTask(20L, 0);
verify(settlementService, times(1)).settle(20L, 9L, "operator"); verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, times(1)).finishFromPda(10L, 20L, 9L, "operator"); verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator"); verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator");
} }
@@ -79,6 +79,30 @@ class PdaPickSettlementServiceTest {
verify(splits).convergePartialPlanItem(21L, 9L, "operator"); verify(splits).convergePartialPlanItem(21L, 9L, "operator");
} }
@Test
void reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 3, 1, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(3));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 3)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 3)));
when(warehouse.settlementTarget("internal-token", 31L, "PDA-SETTLE-1-31"))
.thenReturn(ApiResponse.success(999L));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 2)))));
writes(2, 1);
service.settle(1L, 9L, "operator");
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), eq(999L),
eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(2),
eq(1), eq("REMAINDER"), eq(9L), eq("operator"));
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 999L && request.targetPackageCount() == 2));
}
@Test @Test
void repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain() { void repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL"); LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL");
@@ -37,7 +37,7 @@ public class OrderFulfillmentSplitService {
String hash = hash(req); String hash = hash(req);
List<OrderFulfillmentSplitEntity> replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey()); List<OrderFulfillmentSplitEntity> replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
if (!replay.isEmpty()) { if (!replay.isEmpty()) {
requireMatchingReplay(replay, hash); requireMatchingReplay(replay, req);
return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay); return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay);
} }
@@ -45,7 +45,7 @@ public class OrderFulfillmentSplitService {
if (root == null) throw new BizException(ResultCode.NOT_FOUND, "根订单不存在或不属于该客户"); if (root == null) throw new BizException(ResultCode.NOT_FOUND, "根订单不存在或不属于该客户");
replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey()); replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
if (!replay.isEmpty()) { if (!replay.isEmpty()) {
requireMatchingReplay(replay, hash); requireMatchingReplay(replay, req);
return response(req, root, replay); return response(req, root, replay);
} }
if (!"MASTER".equals(root.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "根订单层级非法"); if (!"MASTER".equals(root.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "根订单层级非法");
@@ -174,14 +174,33 @@ public class OrderFulfillmentSplitService {
|| r.lines().stream().mapToInt(OrderSplitDtos.QuantityLine::packageCount).sum() != r.sourcePackageCount()) || r.lines().stream().mapToInt(OrderSplitDtos.QuantityLine::packageCount).sum() != r.sourcePackageCount())
throw new BizException(ResultCode.VALIDATION_FAILED, "来源明细箱数必须唯一且合计等于来源箱数"); throw new BizException(ResultCode.VALIDATION_FAILED, "来源明细箱数必须唯一且合计等于来源箱数");
} }
private void requireMatchingReplay(List<OrderFulfillmentSplitEntity> rows, String hash) { private void requireMatchingReplay(List<OrderFulfillmentSplitEntity> rows, OrderSplitDtos.CreateRequest request) {
if (rows.stream().anyMatch(row -> !Objects.equals(hash, row.getRequestHash()))) String details = sourceDetails(request);
boolean mismatch = rows.stream().anyMatch(row ->
!Objects.equals(row.getParentOrderId(), request.parentOrderId())
|| !Objects.equals(row.getSourceOrderId(), request.sourceOrderId())
|| !Objects.equals(row.getLoadingPlanId(), request.loadingPlanId())
|| !Objects.equals(row.getContainerBookingId(), request.containerBookingId())
|| !Objects.equals(row.getSourcePackageCount(), request.sourcePackageCount())
|| !Objects.equals(row.getLoadedPackageCount(), request.loadedPackageCount())
|| !Objects.equals(row.getRemainingPackageCount(), request.remainingPackageCount())
|| !Objects.equals(row.getOperationId(), request.operationId())
|| !sameJson(row.getSourceDetailJson(), details));
if (mismatch)
throw new BizException(ResultCode.CONFLICT, "幂等键对应的请求内容不一致"); throw new BizException(ResultCode.CONFLICT, "幂等键对应的请求内容不一致");
} }
private String sourceDetails(OrderSplitDtos.CreateRequest request) { private String sourceDetails(OrderSplitDtos.CreateRequest request) {
try { return json.writeValueAsString(request.lines()); } try { return json.writeValueAsString(request.lines()); }
catch (Exception exception) { throw new BizException(ResultCode.INTERNAL_ERROR, "无法保存来源明细快照"); } catch (Exception exception) { throw new BizException(ResultCode.INTERNAL_ERROR, "无法保存来源明细快照"); }
} }
private boolean sameJson(String left, String right) {
try {
return json.readTree(left).equals(json.readTree(right));
} catch (Exception exception) {
return Objects.equals(left, right);
}
}
private String hash(Object value) { private String hash(Object value) {
try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(); for (byte b : digest) result.append(String.format("%02x", b)); return result.toString(); StringBuilder result = new StringBuilder(); for (byte b : digest) result.append(String.format("%02x", b)); return result.toString();
@@ -94,6 +94,31 @@ class OrderFulfillmentSplitServiceTest {
verify(orders, never()).insert(any(OrderEntity.class)); verify(orders, never()).insert(any(OrderEntity.class));
} }
@Test
void replaysSameBusinessSplitWhenExpectedVersionChangedAfterACompensatingRetry() {
OrderFulfillmentSplitEntity loaded = new OrderFulfillmentSplitEntity();
loaded.setParentOrderId(100L); loaded.setSourceOrderId(100L); loaded.setChildOrderId(100L);
loaded.setLoadingPlanId(701L); loaded.setContainerBookingId(801L);
loaded.setSourcePackageCount(20); loaded.setLoadedPackageCount(8); loaded.setRemainingPackageCount(12);
loaded.setOperationId("op-key-retry"); loaded.setSourceDetailJson("[{\"sourceDetailId\":501,\"packageCount\":10},{\"sourceDetailId\":502,\"packageCount\":10}]");
loaded.setSplitRole("LOADED");
OrderFulfillmentSplitEntity remainder = new OrderFulfillmentSplitEntity();
remainder.setParentOrderId(100L); remainder.setSourceOrderId(100L); remainder.setChildOrderId(200L);
remainder.setLoadingPlanId(701L); remainder.setContainerBookingId(801L);
remainder.setSourcePackageCount(20); remainder.setLoadedPackageCount(8); remainder.setRemainingPackageCount(12);
remainder.setOperationId("op-key-retry"); remainder.setSourceDetailJson(loaded.getSourceDetailJson());
remainder.setSplitRole("REMAINDER");
when(splits.findByIdempotency(10, 100, "key-retry")).thenReturn(List.of(loaded, remainder));
when(splits.lockOrder(10, 100)).thenReturn(parent());
OrderSplitDtos.CreateRequest retry = new OrderSplitDtos.CreateRequest(10L, 100L, 100L, 701L, 801L,
20, 8, 12, 99, "key-retry", "op-key-retry", List.of(
new OrderSplitDtos.QuantityLine(501L, 10), new OrderSplitDtos.QuantityLine(502L, 10)), "another-operator");
assertThat(service.split(retry, 9).remainingPackageCount()).isEqualTo(12);
verify(orders, never()).insert(any(OrderEntity.class));
}
@Test @Test
void rejectsConsumedOrMismatchedRemainderSource() { void rejectsConsumedOrMismatchedRemainderSource() {
OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity(); OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity();
@@ -83,6 +83,15 @@ public class InternalPalletLocationController {
return ApiResponse.success(service.settle(request)); return ApiResponse.success(service.settle(request));
} }
@GetMapping("/settlements/target")
@Operation(summary = "查询 PDA 结算目标", description = "跨服务补偿时复用已登记的子入库单资源 ID")
public ApiResponse<Long> settlementTarget(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam long sourceBusinessId, @RequestParam String idempotencyKey) {
verify(token);
return ApiResponse.success(service.settlementTarget(sourceBusinessId, idempotencyKey));
}
@GetMapping("/occupied-resources") @GetMapping("/occupied-resources")
@Operation(summary = "查询业务当前占用资源", description = "PDA提交前使用的只读实时库存事实") @Operation(summary = "查询业务当前占用资源", description = "PDA提交前使用的只读实时库存事实")
public ApiResponse<List<LocationDtos.OccupiedResource>> occupiedResources( public ApiResponse<List<LocationDtos.OccupiedResource>> occupiedResources(
@@ -30,6 +30,13 @@ public interface PalletLocationSettlementOperationMapper extends BaseMapper<Pall
@Param("sourceService") String sourceService, @Param("sourceBusinessId") long sourceBusinessId, @Param("sourceService") String sourceService, @Param("sourceBusinessId") long sourceBusinessId,
@Param("idempotencyKey") String idempotencyKey); @Param("idempotencyKey") String idempotencyKey);
@Select("SELECT target_business_id FROM wh_pallet_location_settlement_operation "
+ "WHERE tenant_id = #{tenantId} AND source_service = #{sourceService} "
+ "AND source_business_id = #{sourceBusinessId} AND idempotency_key = #{idempotencyKey} LIMIT 1")
Long findTargetBusinessId(@Param("tenantId") long tenantId, @Param("sourceService") String sourceService,
@Param("sourceBusinessId") long sourceBusinessId,
@Param("idempotencyKey") String idempotencyKey);
@Update(""" @Update("""
UPDATE wh_pallet_location_settlement_operation UPDATE wh_pallet_location_settlement_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot}, SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
@@ -31,6 +31,11 @@ public class PalletLocationSettlementOperationRepository {
return mapper.lockByKey(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE, sourceBusinessId, idempotencyKey); return mapper.lockByKey(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE, sourceBusinessId, idempotencyKey);
} }
public Long findTargetBusinessId(long sourceBusinessId, String idempotencyKey) {
return mapper.findTargetBusinessId(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE,
sourceBusinessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) { public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot); mapper.complete(id, responseSnapshot);
} }
@@ -238,6 +238,14 @@ public class PalletLocationAllocationService {
return result; return result;
} }
/** 查询已创建的结算目标,供头程在跨服务事务回滚后恢复同一子入库单 ID。 */
public Long settlementTarget(long sourceBusinessId, String idempotencyKey) {
if (sourceBusinessId < 1 || idempotencyKey == null || idempotencyKey.isBlank()) {
throw new BizException(ResultCode.BAD_REQUEST, "结算查询参数不能为空");
}
return settlementOperations.findTargetBusinessId(sourceBusinessId, idempotencyKey.trim());
}
@Transactional @Transactional
@BusinessAudit(event = "PALLET_LOCATION_REBIND_TEMPORARY", resourceType = "LOCATION_OCCUPANCY", @BusinessAudit(event = "PALLET_LOCATION_REBIND_TEMPORARY", resourceType = "LOCATION_OCCUPANCY",
resourceId = "#request.occupancyId", description = "A0临时库位换绑到普通库位") resourceId = "#request.occupancyId", description = "A0临时库位换绑到普通库位")