feat(firstleg): add atomic PDA batch picking endpoint
This commit is contained in:
+16
@@ -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]
|
||||
|
||||
+2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user