From 9f455b6c0bde9e568a5a12712d13c7ccf04806db Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 11:24:59 +0800 Subject: [PATCH] feat(firstleg): add atomic PDA batch picking endpoint --- .../PlatformLoadingPdaController.java | 16 ++ .../baiyue/firstleg/dto/LoadingPickDtos.java | 23 ++ .../mapper/LoadingPickItemMapper.java | 8 + .../firstleg/service/LoadingPickService.java | 222 ++++++++++++++++++ .../openapi/phase3-pda-convergence-v1.yaml | 37 +++ .../PdaConvergenceOpenApiContractTest.java | 2 + ...tformLoadingPdaControllerContractTest.java | 1 + .../service/LoadingPickServiceTest.java | 93 ++++++++ 8 files changed, 402 insertions(+) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 26bc461fa..e42b00fc8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -9,13 +9,16 @@ 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.Operation; +import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -24,6 +27,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -92,6 +96,18 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username())); } + @PostMapping("/tasks/{taskId}/batch") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse batch(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestHeader("Idempotency-Key") + @NotBlank @Size(max = 128) String idempotencyKey, + @Valid @RequestBody BatchRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.batch(taskId, idempotencyKey, request, + user.userId(), user.username())); + } + @PostMapping("/tasks/{taskId}/review") @PreAuthorize("hasAuthority('platform:firstleg:pda:review')") public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 6f5f1619c..e23e5f5ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -3,9 +3,11 @@ package com.baiyue.firstleg.dto; import com.fasterxml.jackson.annotation.JsonAlias; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; import java.util.List; import java.time.LocalDateTime; @@ -53,6 +55,27 @@ public final class LoadingPickDtos { ) { } + /** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */ + public record BatchRequest( + @NotNull @Min(1) Long warehouseId, + @NotBlank @Size(max = 64) String containerNo, + @NotNull @Min(0) Integer expectedVersion, + @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items + ) { + } + + public record BatchItemRequest( + @NotNull @Min(1) Long itemId, + @NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP") String action, + @Min(1) Integer pickedPackageCount, + @Min(1) Integer finalPickedPackageCount, + @Pattern(regexp = "AUTO|MANUAL") String releaseMode, + @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, + @NotNull @Min(0) Integer version, + @Size(max = 500) String reason + ) { + } + public record ClaimRequest( @NotNull @Min(0) Integer expectedAssignmentVersion, @NotBlank @Size(max = 128) String operationId diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index c4d1f6e4f..a2de1a488 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -80,9 +80,13 @@ public interface LoadingPickItemMapper extends BaseMapper @Select(""" SELECT pick_item.* FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + AND plan.id = pick_task.loading_plan_id JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id + AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0 WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0 ORDER BY pick_item.id ASC @@ -93,9 +97,13 @@ public interface LoadingPickItemMapper extends BaseMapper @Select(""" SELECT pick_item.* FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + AND plan.id = pick_task.loading_plan_id JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + JOIN fl_inbound_order inbound_order ON inbound_order.id = plan_item.inbound_id + AND inbound_order.tenant_id = plan_item.account_id AND inbound_order.deleted = 0 WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId} AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0 """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index e579abbdb..232d0c134 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -13,6 +13,8 @@ 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.Operation; +import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -32,6 +34,13 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.HashSet; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Map; +import java.util.HashMap; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** 装柜中 PDA 拣货任务及明细同步。 */ @Service @@ -228,6 +237,52 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } + /** + * 原子提交 PDA 在线草稿。所有明细先在任务行锁内完成快照校验,再统一写入和结算; + * 任一明细或跨服务资源结算失败都会回滚本地批次。 + */ + @Transactional + public Task batch(long taskId, String idempotencyKey, BatchRequest request, + long operatorId, String operatorName) { + String operationId = operation(idempotencyKey); + String requestHash = batchRequestHash(taskId, request); + Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + + LoadingPickTaskEntity task = requiredTaskForUpdate(taskId); + replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); + if (replay != null) return replay; + if (!"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务已完成,不能重复提交整批拣货"); + } + if (!Objects.equals(request.expectedVersion(), task.getVersion())) { + throw conflict("PDA任务版本已变化,请刷新后重试"); + } + if (!task.getContainerNo().equals(request.containerNo().trim())) { + throw conflict("柜号与PDA任务不一致"); + } + LoadingPickAssignmentEntity assignment = repository.assignment(taskId); + if (assignment == null) throw conflict("请先领取PDA拣货任务"); + if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) { + throw conflict("PDA拣货任务已分配给其他作业员"); + } + + List lines = validateBatchLines(task, request); + for (BatchLine line : lines) { + applyBatchLine(taskId, line, operationId, operatorId, operatorName); + } + if (!repository.allTaskItemsResolved(taskId)) { + throw conflict("整批提交必须处理本任务全部待拣明细"); + } + if (repository.touchTask(taskId, request.expectedVersion()) != 1) { + throw conflict("PDA任务版本已变化,请刷新后重试"); + } + insertOperation(taskId, operationId, requestHash, "BATCH", operatorId, operatorName); + settleAndFinish(task, operatorId, operatorName); + repository.syncInboundProjections(taskId, operatorId, operatorName); + return view(requiredTask(taskId), request.warehouseId()); + } + @Transactional public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) { String operationId = operation(request.operationId()); @@ -328,6 +383,12 @@ public class LoadingPickService { return task; } + private LoadingPickTaskEntity requiredTaskForUpdate(long taskId) { + LoadingPickTaskEntity task = repository.taskForUpdate(taskId); + if (task == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在"); + return task; + } + private LoadingPickTaskEntity requiredLoadingTask(long taskId) { LoadingPickTaskEntity task = requiredTask(taskId); if (!"LOADING".equals(task.getTaskStatus())) { @@ -382,6 +443,142 @@ public class LoadingPickService { workflowStatus, task.getVersion(), assignment, review, items); } + private List validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) { + long taskId = task.getId(); + List allItems = repository.items(taskId); + List scopedItems = repository.items(taskId, request.warehouseId()); + if (allItems.isEmpty() || scopedItems.size() != allItems.size()) { + throw conflict("PDA任务柜次、仓库或客户归属不一致"); + } + Map itemsById = scopedItems.stream().collect(Collectors.toMap( + LoadingPickItemEntity::getId, item -> item)); + Set unresolvedIds = allItems.stream().filter(item -> !isResolved(item)) + .map(LoadingPickItemEntity::getId).collect(Collectors.toSet()); + Set requestIds = request.items().stream().map(BatchItemRequest::itemId) + .collect(Collectors.toSet()); + if (requestIds.size() != request.items().size()) { + throw new BizException(ResultCode.BAD_REQUEST, "整批明细不能包含重复itemId"); + } + if (!requestIds.equals(unresolvedIds)) { + throw conflict("整批提交必须且只能包含当前任务全部待拣明细"); + } + + Map inboundHasWork = new HashMap<>(); + for (BatchItemRequest requestItem : request.items()) { + LoadingPickItemEntity item = itemsById.get(requestItem.itemId()); + if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null + || item.getInboundDetailId() == null) { + throw conflict("PDA明细不属于当前任务、柜次、仓库或客户"); + } + if (!"SKIP".equals(requestItem.action())) { + inboundHasWork.put(item.getInboundId(), true); + } + } + + List result = new ArrayList<>(); + for (BatchItemRequest requestItem : request.items()) { + LoadingPickItemEntity item = itemsById.get(requestItem.itemId()); + if (!Objects.equals(requestItem.version(), item.getVersion())) { + throw conflict("PDA明细版本已变化,请刷新后重试"); + } + String action = requestItem.action().trim().toUpperCase(); + ReleaseSelection release = null; + if ("PICK".equals(action)) { + requireNoUnexpectedBatchFields(requestItem, false, true); + release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds()); + int current = count(item.getPickedPackageCount()); + int planned = count(item.getPlannedPackageCount()); + if (requestItem.pickedPackageCount() == null + || current + requestItem.pickedPackageCount() != planned) { + throw conflict("PICK必须提交该明细全部剩余箱数"); + } + } else if ("PARTIAL".equals(action)) { + requireNoUnexpectedBatchFields(requestItem, true, false); + release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds()); + int current = count(item.getPickedPackageCount()); + int planned = count(item.getPlannedPackageCount()); + if (requestItem.finalPickedPackageCount() == null + || requestItem.finalPickedPackageCount() <= current + || requestItem.finalPickedPackageCount() >= planned) { + throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数"); + } + } else if ("SKIP".equals(action)) { + if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null + || StringUtils.hasText(requestItem.releaseMode()) + || requestItem.locationIds() != null && !requestItem.locationIds().isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量或释放库位"); + } + if (!StringUtils.hasText(requestItem.reason())) { + throw new BizException(ResultCode.BAD_REQUEST, "SKIP必须填写异常原因"); + } + if (count(item.getPickedPackageCount()) != 0 || !"PENDING".equals(item.getItemStatus()) + || Boolean.TRUE.equals(inboundHasWork.get(item.getInboundId())) + || hasExistingPickedItem(allItems, item.getInboundId(), item.getId())) { + throw conflict("同一入库单已有拣货记录,不能在整批中跳拣"); + } + } else { + throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL或SKIP"); + } + result.add(new BatchLine(item, requestItem, action, release, request.warehouseId())); + } + result.sort(Comparator.comparing(line -> line.item().getId())); + return result; + } + + private void applyBatchLine(long taskId, BatchLine line, String batchOperationId, + long operatorId, String operatorName) { + LoadingPickItemEntity item = line.item(); + BatchItemRequest request = line.request(); + int changed; + int pickedCount; + if ("PICK".equals(line.action())) { + pickedCount = request.pickedPackageCount(); + changed = repository.pick(item.getId(), pickedCount, line.release().mode(), + line.release().json(), request.version(), line.warehouseId()); + } else if ("PARTIAL".equals(line.action())) { + pickedCount = request.finalPickedPackageCount(); + changed = repository.partial(item.getId(), pickedCount, line.release().mode(), + line.release().json(), request.version(), line.warehouseId()); + } else { + pickedCount = 0; + changed = repository.skip(item.getId(), request.version(), line.warehouseId()); + } + if (changed != 1) throw conflict("整批明细库存、库位或版本快照已变化,请刷新后重试"); + String itemOperationId = "BATCH-" + hash(batchOperationId + '|' + item.getId()).substring(0, 48); + requireNoTaskOperation(itemOperationId); + if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交"); + String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version() + + '|' + pickedCount + '|' + (line.release() == null ? "" : line.release().mode()) + + '|' + (line.release() == null ? "" : line.release().json()) + '|' + trim(request.reason())); + insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount, + trim(request.reason()), operatorId, operatorName); + } + + private static void requireNoUnexpectedBatchFields(BatchItemRequest request, + boolean requireFinal, boolean requirePicked) { + if (requireFinal && request.pickedPackageCount() != null + || requirePicked && request.finalPickedPackageCount() != null + || StringUtils.hasText(request.reason())) { + throw new BizException(ResultCode.BAD_REQUEST, "批量拣货明细包含与action不匹配的字段"); + } + } + + private static boolean hasExistingPickedItem(List items, Long inboundId, + Long excludedItemId) { + return items.stream().anyMatch(item -> !Objects.equals(item.getId(), excludedItemId) + && Objects.equals(item.getInboundId(), inboundId) && count(item.getPickedPackageCount()) > 0); + } + + private static boolean isResolved(LoadingPickItemEntity item) { + return "PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()) + || "SKIPPED".equals(item.getItemStatus()); + } + + private static int count(Integer value) { + if (value == null || value < 0) throw conflict("PDA明细箱数快照不合法"); + return value; + } + /** * 先触碰仍处于 LOADING 的任务,再结算并推进装柜计划。计划完成触发器会同步把任务改为 * COMPLETED,因此绝不能在 finishFromPda 之后再以 LOADING 状态触碰任务。 @@ -425,6 +622,7 @@ public class LoadingPickService { private static String operation(String value) { String result = trim(value); if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空"); + if (result.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId长度不能超过128"); return result; } @@ -461,6 +659,28 @@ public class LoadingPickService { } } + private static String batchRequestHash(long taskId, BatchRequest request) { + if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo()) + || request.expectedVersion() == null || request.items() == null || request.items().isEmpty()) { + throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整"); + } + StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|') + .append(request.warehouseId()).append('|').append(request.containerNo().trim()).append('|') + .append(request.expectedVersion()); + request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId, + Comparator.nullsFirst(Long::compareTo))) + .forEach(item -> { + canonical.append('|').append(item.itemId()).append('|').append(trim(item.action())) + .append('|').append(item.pickedPackageCount()).append('|') + .append(item.finalPickedPackageCount()).append('|').append(trim(item.releaseMode())) + .append('|').append(item.version()).append('|').append(trim(item.reason())).append('|'); + List locations = item.locationIds() == null ? List.of() + : item.locationIds().stream().sorted().toList(); + canonical.append(locations); + }); + return hash(canonical.toString()); + } + private static ReleaseSelection releaseSelection(String requestedMode, List requestedLocationIds) { String mode = requestedMode == null || requestedMode.isBlank() ? (requestedLocationIds == null ? "AUTO" : "MANUAL") : requestedMode.trim().toUpperCase(); @@ -478,6 +698,8 @@ public class LoadingPickService { return new ReleaseSelection(mode, json); } + private record BatchLine(LoadingPickItemEntity item, BatchItemRequest request, String action, + ReleaseSelection release, long warehouseId) {} private record ReleaseSelection(String mode, String json) {} private static String trim(String value) { diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 28d1d2c48..22f7edc2c 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -44,6 +44,17 @@ paths: summary: Resolve an untouched assigned item as skipped parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] responses: {'200': {description: Task awaiting review when every item is resolved}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/batch: + post: + summary: Atomically submit the complete online PDA draft once + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}} + responses: + '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 @@ -78,6 +89,32 @@ components: assigneeName: {type: string, maxLength: 64} expectedAssignmentVersion: {type: integer, minimum: 0} operationId: {type: string, maxLength: 128} + BatchRequest: + type: object + required: [warehouseId, containerNo, expectedVersion, items] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + containerNo: {type: string, maxLength: 64} + expectedVersion: {type: integer, minimum: 0} + items: + type: array + minItems: 1 + maxItems: 500 + items: {$ref: '#/components/schemas/BatchItemRequest'} + BatchItemRequest: + type: object + required: [itemId, action, version] + properties: + itemId: {type: integer, format: int64, minimum: 1} + action: {type: string, enum: [PICK, PARTIAL, SKIP]} + pickedPackageCount: {type: integer, minimum: 1, description: Incremental count required by PICK} + finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL} + releaseMode: {type: string, enum: [AUTO, MANUAL]} + reservationIds: + type: array + items: {type: integer, format: int64, minimum: 1} + version: {type: integer, minimum: 0} + reason: {type: string, maxLength: 500, description: Required by SKIP} ReviewRequest: type: object required: [expectedTaskVersion, operationId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 890a564c5..a665f0b08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -19,9 +19,11 @@ class PdaConvergenceOpenApiContractTest { .contains("/tasks/{taskId}/items/{itemId}/pick:") .contains("/tasks/{taskId}/items/{itemId}/partial:") .contains("/tasks/{taskId}/items/{itemId}/skip:") + .contains("/tasks/{taskId}/batch:") .contains("/tasks/{taskId}/review:") .contains("/tasks/{taskId}/operations:") .contains("expectedAssignmentVersion") + .contains("BatchRequest") .contains("expectedTaskVersion"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index d6fe41f1d..156dbddbf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -18,6 +18,7 @@ class PlatformLoadingPdaControllerContractTest { authority("pick", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PickRequest.class); authority("partial", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, PartialPickRequest.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("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 3e92b9f46..afbb86ec5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -5,6 +5,8 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; 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.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -18,6 +20,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -25,6 +28,8 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; @SpringJUnitConfig(LoadingPickService.class) class LoadingPickServiceTest { @@ -133,6 +138,94 @@ class LoadingPickServiceTest { verify(repository).syncInboundProjections(20L, 9L, "operator"); } + @Test + void atomicallySubmitsCompleteBatchAndReplaysSameResult() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity picked = item(); + LoadingPickItemEntity skipped = item(); + skipped.setId(31L); + skipped.setLoadingPlanItemId(41L); + skipped.setInboundId(51L); + skipped.setInboundDetailId(61L); + List items = List.of(picked, skipped); + AtomicReference operation = new AtomicReference<>(); + when(repository.history(any(String.class))).thenReturn(null); + when(repository.operation("batch-1")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { + Object value = invocation.getArgument(0); + if (value instanceof LoadingPickOperationEntity entity && "BATCH".equals(entity.getActionType())) { + operation.set(entity); + } + return null; + }).when(repository).insert(any(LoadingPickOperationEntity.class)); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(items); + when(repository.items(20L, 101L)).thenReturn(items); + when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1); + when(repository.skip(31L, 0, 101L)).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, List.of( + new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), + new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); + + service.batch(20L, "batch-1", request, 9L, "operator"); + service.batch(20L, "batch-1", request, 9L, "operator"); + + 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(repository, times(1)).syncInboundProjections(20L, 9L, "operator"); + } + + @Test + void rejectsWholeBatchBeforeWritingWhenAnyLineIsInvalid() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity first = item(); + LoadingPickItemEntity invalid = item(); + invalid.setId(31L); + invalid.setLoadingPlanItemId(41L); + invalid.setInboundId(51L); + invalid.setInboundDetailId(61L); + invalid.setPlannedPackageCount(4); + List items = List.of(first, invalid); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.assignment(20L)).thenReturn(assignment()); + when(repository.items(20L)).thenReturn(items); + when(repository.items(20L, 101L)).thenReturn(items); + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, List.of( + new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), + new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); + + assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator")) + .isInstanceOf(BizException.class); + + verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class)); + verify(repository, never()).partial(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class)); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + verify(settlementService, never()).settle(20L, 9L, "operator"); + } + + @Test + void rejectsBatchTaskVersionConflictBeforeWriting() { + LoadingPickTaskEntity task = task(); + task.setVersion(3); + when(repository.taskForUpdate(20L)).thenReturn(task); + BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, List.of( + new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); + + assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) + .isInstanceOf(BizException.class); + + verify(repository, never()).items(20L); + verify(repository, never()).touchTask(any(Long.class), any(Integer.class)); + } + private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) { when(repository.history(operationId)).thenReturn(null); when(repository.task(20L)).thenReturn(task);