第三期装柜验收优化修改

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
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
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 = '仍有未完成拣货明细,不能完成装柜';
END IF;
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),
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 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任务级幂等审计';
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update;
@@ -9546,7 +9546,7 @@ BEGIN
END IF;
ALTER TABLE fl_loading_pick_operation
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$$
DELIMITER ;
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', 'updated_at', '明细分配记录最后更新时间');
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', 'id', 'PDA任务操作审计记录ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_operation', 'operation_id', '全局唯一操作ID,用于幂等重放');
@@ -38,7 +38,12 @@ public interface WarehousePalletLocationClient {
@PostMapping("/settlements")
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")
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.AssignRequest;
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.BatchRequest;
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()));
}
@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")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Operation>> operations(@AuthenticationPrincipal AuthenticatedUser user,
@@ -8,7 +8,7 @@ public enum SmartLoadingStatus {
EDITING,
/** 拣货中:已生成拣货任务,仍可追加或调整未拣货明细。 */
LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
/** 装柜完成:PDA 人工确认装柜完成后进入,计划、明细和货柜信息只读。 */
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,
String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
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
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count &lt; #{finalCount}
AND #{finalCount} &lt; planned_package_count
AND picked_package_count < #{finalCount}
AND #{finalCount} < planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
@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.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
@@ -25,6 +27,8 @@ import java.time.LocalDateTime;
public class ContainerService {
@Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses;
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickService loadingPickService;
@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) {
@@ -83,7 +87,19 @@ public class ContainerService {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) 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) {
@@ -107,11 +107,11 @@ public class LoadingListService {
}
/**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
* PDA 人工确认装柜完成后调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@Transactional
@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) {
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) {
@@ -139,7 +139,7 @@ public class LoadingListService {
"LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货");
"PDA已人工确认装柜完成");
}
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.AssignRequest;
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.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
@@ -327,39 +328,37 @@ public class LoadingPickService {
@Transactional
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 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);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经复核");
List<LoadingPickItemEntity> taskItems = repository.items(taskId);
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.allTaskItemsResolved(taskId)) {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
settlementService.settle(taskId, operatorId, operatorName);
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
}
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
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);
repository.syncInboundProjections(taskId, operatorId, operatorName);
return view(requiredTask(taskId), null);
@@ -408,13 +407,7 @@ public class LoadingPickService {
}
private void settleAndFinish(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (!repository.allTaskItemsResolved(task.getId())) {
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) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
@@ -161,7 +161,11 @@ public class PdaPickSettlementService {
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"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();
String idempotencyKey = "pda-task-" + taskId;
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,
List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) {
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),
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 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任务级幂等审计';
DROP TRIGGER IF EXISTS trg_loading_pick_review_before_update;
@@ -653,7 +653,7 @@ BEGIN
AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
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 = '仍有未完成拣货明细,不能完成装柜';
END IF;
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}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review:
post:
summary: Review all resolved items, settle partial and skipped quantities, then complete the plan
summary: 人工确认装柜完成的兼容入口
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ReviewRequest'}}}
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:
get:
summary: Read immutable task-level claim, assignment and review audit records
@@ -198,6 +208,13 @@ components:
expectedTaskVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
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:
type: object
required: [taskId, taskVersion, containerNo, warehouseId, capturedAt, snapshotToken, inbounds]
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PdaConvergenceOpenApiContractTest {
@Test
void publishesClaimAssignWorkAndReviewRoutes() throws Exception {
void publishesClaimAssignWorkAndManualCompletionRoutes() throws Exception {
String yaml = new ClassPathResource("openapi/phase3-pda-convergence-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
@@ -22,11 +22,13 @@ class PdaConvergenceOpenApiContractTest {
.contains("/tasks/{taskId}/items/{itemId}/skip:")
.contains("/tasks/{taskId}/batch:")
.contains("/tasks/{taskId}/review:")
.contains("/tasks/{taskId}/complete:")
.contains("/tasks/{taskId}/operations:")
.contains("expectedAssignmentVersion")
.contains("BatchRequest")
.contains("inventorySnapshotToken")
.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("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("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.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_update")
.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("DROP INDEX uk_loading_evidence_type")
.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("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.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("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.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.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach;
@@ -33,6 +35,8 @@ import static org.mockito.Mockito.when;
class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickService loadingPickService;
@Resource private ContainerService service;
@BeforeEach
@@ -118,6 +122,27 @@ class ContainerServiceTest {
.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
@DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() {
@@ -109,7 +109,7 @@ class LoadingListServiceTest {
verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货"));
eq(9L), eq("operator"), eq("PDA已人工确认装柜完成"));
}
@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.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
@@ -67,7 +68,7 @@ class LoadingPickServiceTest {
}
@Test
void touchesTaskBeforeCompletingLastPickedItem() {
void keepsResourcesLockedAfterLastPickedItemUntilManualCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
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");
InOrder order = inOrder(repository, settlementService, loadingListService);
order.verify(repository).touchTask(20L, 0);
order.verify(settlementService).settle(20L, 9L, "operator");
order.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
order.verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
@@ -131,7 +131,7 @@ class LoadingPickServiceTest {
}
@Test
void touchesTaskBeforeCompletingLastPartialItem() {
void keepsPartialPickLockedUntilManualCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePartial(task, item, "partial-1");
@@ -141,11 +141,10 @@ class LoadingPickServiceTest {
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"),
9L, "operator");
InOrder order = inOrder(repository, settlementService, loadingListService);
order.verify(repository).touchTask(20L, 0);
order.verify(settlementService).settle(20L, 9L, "operator");
order.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
order.verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
}
@Test
@@ -171,7 +170,7 @@ class LoadingPickServiceTest {
}
@Test
void reviewSettlesResolvedItemsAndCompletesPlan() {
void manualCompletionSettlesResolvedItemsAndCompletesPlan() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
item.setPlannedPackageCount(5);
@@ -181,15 +180,15 @@ class LoadingPickServiceTest {
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).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.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(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");
}
@@ -237,8 +236,8 @@ class LoadingPickServiceTest {
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).skip(31L, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
verify(settlementService, times(1)).settle(20L, 9L, "operator");
verify(loadingListService, times(1)).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator");
}
@@ -79,6 +79,30 @@ class PdaPickSettlementServiceTest {
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
void repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL");
@@ -37,7 +37,7 @@ public class OrderFulfillmentSplitService {
String hash = hash(req);
List<OrderFulfillmentSplitEntity> replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
if (!replay.isEmpty()) {
requireMatchingReplay(replay, hash);
requireMatchingReplay(replay, req);
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, "根订单不存在或不属于该客户");
replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey());
if (!replay.isEmpty()) {
requireMatchingReplay(replay, hash);
requireMatchingReplay(replay, req);
return response(req, root, replay);
}
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())
throw new BizException(ResultCode.VALIDATION_FAILED, "来源明细箱数必须唯一且合计等于来源箱数");
}
private void requireMatchingReplay(List<OrderFulfillmentSplitEntity> rows, String hash) {
if (rows.stream().anyMatch(row -> !Objects.equals(hash, row.getRequestHash())))
private void requireMatchingReplay(List<OrderFulfillmentSplitEntity> rows, OrderSplitDtos.CreateRequest request) {
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, "幂等键对应的请求内容不一致");
}
private String sourceDetails(OrderSplitDtos.CreateRequest request) {
try { return json.writeValueAsString(request.lines()); }
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) {
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();
@@ -94,6 +94,31 @@ class OrderFulfillmentSplitServiceTest {
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
void rejectsConsumedOrMismatchedRemainderSource() {
OrderFulfillmentSplitEntity source = new OrderFulfillmentSplitEntity();
@@ -83,6 +83,15 @@ public class InternalPalletLocationController {
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")
@Operation(summary = "查询业务当前占用资源", description = "PDA提交前使用的只读实时库存事实")
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("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 wh_pallet_location_settlement_operation
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);
}
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) {
mapper.complete(id, responseSnapshot);
}
@@ -238,6 +238,14 @@ public class PalletLocationAllocationService {
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
@BusinessAudit(event = "PALLET_LOCATION_REBIND_TEMPORARY", resourceType = "LOCATION_OCCUPANCY",
resourceId = "#request.occupancyId", description = "A0临时库位换绑到普通库位")