feat(firstleg): add atomic PDA batch picking endpoint

This commit is contained in:
2026-08-11 11:24:59 +08:00
parent 7aa2b507f6
commit 9f455b6c0b
8 changed files with 402 additions and 0 deletions
@@ -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<Task> 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<Task> review(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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
@@ -80,9 +80,13 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
@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<LoadingPickItemEntity>
@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
""")
@@ -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<BatchLine> 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<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
long taskId = task.getId();
List<LoadingPickItemEntity> allItems = repository.items(taskId);
List<LoadingPickItemEntity> scopedItems = repository.items(taskId, request.warehouseId());
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致");
}
Map<Long, LoadingPickItemEntity> itemsById = scopedItems.stream().collect(Collectors.toMap(
LoadingPickItemEntity::getId, item -> item));
Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item))
.map(LoadingPickItemEntity::getId).collect(Collectors.toSet());
Set<Long> 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<Long, Boolean> 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<BatchLine> 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<LoadingPickItemEntity> 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<Long> 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<Long> 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) {
@@ -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]
@@ -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");
}
}
@@ -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);
}
@@ -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<LoadingPickItemEntity> items = List.of(picked, skipped);
AtomicReference<LoadingPickOperationEntity> 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<LoadingPickItemEntity> 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);