From ac3ce3703e32f26fbd876be89db85c549bb6f5be Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:33:31 +0800 Subject: [PATCH] fix(firstleg): preserve sealed pallet identity in atomic PDA picking --- .../baiyue/firstleg/dto/LoadingPickDtos.java | 12 +- .../entity/LoadingPickItemEntity.java | 5 + .../mapper/LoadingPickItemMapper.java | 15 ++- .../repository/LoadingPickRepository.java | 4 +- .../firstleg/service/LoadingPickService.java | 71 ++++++++-- .../openapi/phase3-pda-convergence-v1.yaml | 27 +++- .../PdaConvergenceOpenApiContractTest.java | 5 + .../LoadingPickPdaTaskSqlContractTest.java | 14 ++ .../service/LoadingPickServiceTest.java | 121 ++++++++++++++++-- scripts/admin-test-env-modules/README.md | 2 + .../admin-test-env-modules/phase3-loading.mjs | 69 ++++++++-- ...-phase4-producer-fixture-contract.test.mjs | 25 +++- 12 files changed, 323 insertions(+), 47 deletions(-) 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 436094503..0827228d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -59,11 +59,12 @@ public final class LoadingPickDtos { public record BatchItemRequest( @NotNull @Min(1) Long itemId, - @NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP") String action, + @NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP|PICK_PALLET") String action, @Min(1) Integer pickedPackageCount, @Min(1) Integer finalPickedPackageCount, @NotNull @Min(0) Integer version, - @Size(max = 500) String reason + @Size(max = 500) String reason, + @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo ) { } @@ -98,6 +99,7 @@ public final class LoadingPickDtos { public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId, + String unitType, Long palletId, String palletNo, Boolean indivisible, String orderNo, String inboundCode, String cargoName, List boxCodes, List inventoryResources, Integer plannedPackageCount, Integer pickedPackageCount, @@ -152,6 +154,10 @@ public final class LoadingPickDtos { public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId, String cabinetNo, Integer taskVersion, String currentContainerNo, Long warehouseId, LocalDateTime capturedAt, String snapshotToken, - List inbounds) { + List inbounds, List units) { + } + + public record InventoryUnit(Long pickItemId, Long loadingPlanItemId, Long inboundId, + String unitType, Long palletId, String palletNo, Boolean indivisible) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java index 1c8349f7b..fd04498d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.Version; import lombok.Getter; @@ -18,6 +19,10 @@ public class LoadingPickItemEntity { private Long id; private Long pickTaskId; private Long loadingPlanItemId; + @TableField(exist = false) private String unitType; + @TableField(exist = false) private Long palletId; + @TableField(exist = false) private String palletNo; + @TableField(exist = false) private Boolean indivisible; private Long inboundId; private Long inboundDetailId; private Integer plannedPackageCount; 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 13ca4ad68..f8249fb34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -11,6 +11,17 @@ import java.util.List; @Mapper public interface LoadingPickItemMapper extends BaseMapper { + @Select(""" + SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible + FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task task ON task.id = pick_item.pick_task_id + JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id + AND plan_item.loading_plan_id = task.loading_plan_id + WHERE pick_item.pick_task_id = #{taskId} + ORDER BY pick_item.id ASC + """) + List selectTaskItemsWithPlan(@Param("taskId") long taskId); + @Select(""" SELECT pick_item.id AS pickItemId, plan_item.account_id AS tenantId, plan_item.order_no AS orderNo, plan_item.inbound_code AS inboundCode, label.cargo_name AS cargoName, @@ -133,7 +144,7 @@ public interface LoadingPickItemMapper extends BaseMapper record InboundOwner(Long tenantId, Long inboundId) {} @Select(""" - SELECT pick_item.* + SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible 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 @@ -150,7 +161,7 @@ public interface LoadingPickItemMapper extends BaseMapper @Param("warehouseId") long warehouseId); @Select(""" - SELECT pick_item.* + SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible 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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 9ce81a08f..f3b1a1390 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -105,9 +105,7 @@ public class LoadingPickRepository { } public List items(long taskId) { - return items.selectList(new LambdaQueryWrapper() - .eq(LoadingPickItemEntity::getPickTaskId, taskId) - .orderByAsc(LoadingPickItemEntity::getId)); + return items.selectTaskItemsWithPlan(taskId); } public List cartonLabels(long taskId) { 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 7782981bc..36efb3977 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -21,6 +21,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource; import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory; import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; +import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit; import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask; import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; @@ -484,11 +485,11 @@ public class LoadingPickService { List lines = validateBatchLines(task, request); validateInventorySnapshot(task, request, lines); - lines.forEach(line -> requirePackageUnit(line.item())); + lines.stream().filter(line -> !"PICK_PALLET".equals(line.action())).forEach(line -> requirePackageUnit(line.item())); for (BatchLine line : lines) { applyBatchLine(taskId, line, operationId, operatorId, operatorName); } - if (lines.stream().anyMatch(line -> "PICK".equals(line.action()) || "PARTIAL".equals(line.action()))) { + if (lines.stream().anyMatch(line -> "PICK".equals(line.action()) || "PARTIAL".equals(line.action()) || "PICK_PALLET".equals(line.action()))) { startPickingIfNeeded(task, operatorId, operatorName); } if (!repository.allTaskItemsResolved(taskId)) { @@ -968,7 +969,8 @@ public class LoadingPickService { Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId(); return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId, item.getInboundId(), - item.getInboundDetailId(), first == null ? null : first.getOrderNo(), + item.getInboundDetailId(), item.getUnitType(), item.getPalletId(), item.getPalletNo(), item.getIndivisible(), + first == null ? null : first.getOrderNo(), first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(), cartons.stream().map(CartonLabelRow::getBoxCode).toList(), inventoryByInbound.getOrDefault(item.getInboundId(), List.of()), item.getPlannedPackageCount(), @@ -1045,7 +1047,22 @@ public class LoadingPickService { throw conflict("PDA明细版本已变化,请刷新后重试"); } String action = requestItem.action().trim().toUpperCase(); - if ("PICK".equals(action)) { + boolean palletUnit = "SEALED_PALLET".equals(item.getUnitType()) || item.getPalletId() != null || Boolean.TRUE.equals(item.getIndivisible()); + if (palletUnit && !"PICK_PALLET".equals(action)) { + throw conflict("一件代发已封托货物必须整托提交,不能普通拣货、部分拣货或跳拣"); + } + if (!"PICK_PALLET".equals(action) && requestItem.palletNo() != null) { + throw new BizException(ResultCode.BAD_REQUEST, "普通箱件动作不能携带托盘码"); + } + if ("PICK_PALLET".equals(action)) { + if (!"SEALED_PALLET".equals(item.getUnitType()) || item.getPalletId() == null + || !Boolean.TRUE.equals(item.getIndivisible()) || !StringUtils.hasText(item.getPalletNo()) + || !item.getPalletNo().equals(requestItem.palletNo())) throw conflict("托盘码与当前装柜整托单元不一致"); + if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null + || StringUtils.hasText(requestItem.reason())) throw new BizException(ResultCode.BAD_REQUEST, "整托拣货不能携带箱数或跳拣原因"); + if (!"PENDING".equals(item.getItemStatus()) || count(item.getPickedPackageCount()) != 0) + throw conflict("托盘已有部分作业,禁止拆托处理"); + } else if ("PICK".equals(action)) { requireNoUnexpectedBatchFields(requestItem, false, true); int current = count(item.getPickedPackageCount()); int planned = count(item.getPlannedPackageCount()); @@ -1075,14 +1092,35 @@ public class LoadingPickService { throw conflict("同一入库单已有拣货记录,不能在整批中跳拣"); } } else { - throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL或SKIP"); + throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL、SKIP或PICK_PALLET"); } result.add(new BatchLine(item, requestItem, action, request.warehouseId())); } + validateWholePallets(result, scopedItems, request.warehouseId()); result.sort(Comparator.comparing(line -> line.item().getId())); return result; } + private void validateWholePallets(List lines, List allItems, long warehouseId) { + Map> groups = lines.stream().filter(line -> "PICK_PALLET".equals(line.action())) + .collect(Collectors.groupingBy(line -> line.item().getPalletId())); + for (var entry : groups.entrySet()) { + List palletItems = allItems.stream().filter(item -> entry.getKey().equals(item.getPalletId())).toList(); + if (palletItems.size() != entry.getValue().size()) throw conflict("整托提交必须包含托盘全部待拣明细,不得拆托"); + var physical = dropshipPalletizationMapper.sealedPalletLines(entry.getKey()); + String palletNo = entry.getValue().get(0).item().getPalletNo(); + if (palletItems.stream().anyMatch(item -> !palletNo.equals(item.getPalletNo()))) throw conflict("托盘装柜快照不一致"); + if (physical.isEmpty() || physical.stream().anyMatch(line -> !"SEALED".equals(line.getPalletStatus()) + || count(line.getCartonCount()) < 1 || !Objects.equals(line.getWarehouseId(), warehouseId) || !palletNo.equals(line.getPalletNo()))) + throw conflict("托盘已失效、尚未封托或不属于当前仓库"); + Map physicalCounts = new HashMap<>(); + physical.forEach(line -> physicalCounts.merge(line.getInboundId() + ":" + line.getInboundDetailId(), count(line.getCartonCount()), Integer::sum)); + Map plannedCounts = new HashMap<>(); + palletItems.forEach(item -> plannedCounts.merge(item.getInboundId() + ":" + item.getInboundDetailId(), count(item.getPlannedPackageCount()), Integer::sum)); + if (!physicalCounts.equals(plannedCounts)) throw conflict("整托计划明细与封托箱数不一致,禁止部分拣货"); + } + } + private List warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) { List allItems = repository.items(task.getId()); List scopedItems = repository.items(task.getId(), warehouseId); @@ -1102,11 +1140,14 @@ public class LoadingPickService { int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum(); return new InboundInventory(inboundId, available, resources); }).toList(); - String token = inventoryToken(task, warehouseId, inbounds); + List units = items.stream().sorted(Comparator.comparing(LoadingPickItemEntity::getId)) + .map(item -> new InventoryUnit(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), + item.getUnitType(), item.getPalletId(), item.getPalletNo(), item.getIndivisible())).toList(); + String token = inventoryToken(task, warehouseId, inbounds, units); var booking = smartLoadingRepository.booking(task.getContainerBookingId()); return new InventorySnapshot(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(), task.getVersion(), booking == null ? null : booking.getContainerNo(), warehouseId, - LocalDateTime.now(), token, inbounds); + LocalDateTime.now(), token, inbounds, units); } private Map tenantByInbound(long taskId) { @@ -1160,7 +1201,7 @@ public class LoadingPickService { } private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId, - List inbounds) { + List inbounds, List units) { StringBuilder canonical = new StringBuilder("INVENTORY|").append(task.getId()).append('|') .append(task.getVersion()).append('|').append(warehouseId); for (InboundInventory inbound : inbounds) { @@ -1171,6 +1212,11 @@ public class LoadingPickService { .append('|').append(resource.availablePackageCount()); } } + for (InventoryUnit unit : units) { + canonical.append("|UNIT|").append(unit.pickItemId()).append('|').append(unit.loadingPlanItemId()) + .append('|').append(unit.inboundId()).append('|').append(unit.unitType()) + .append('|').append(unit.palletId()).append('|').append(unit.palletNo()).append('|').append(unit.indivisible()); + } return hash(canonical.toString()); } @@ -1191,6 +1237,7 @@ public class LoadingPickService { int picked = line == null ? count(item.getPickedPackageCount()) : "PICK".equals(line.action()) ? count(item.getPickedPackageCount()) + line.request().pickedPackageCount() + : "PICK_PALLET".equals(line.action()) ? count(item.getPlannedPackageCount()) : "PARTIAL".equals(line.action()) ? line.request().finalPickedPackageCount() : 0; requestedByInbound.merge(item.getInboundId(), picked, Integer::sum); } @@ -1207,8 +1254,8 @@ public class LoadingPickService { BatchItemRequest request = line.request(); int changed; int pickedCount; - if ("PICK".equals(line.action())) { - pickedCount = request.pickedPackageCount(); + if ("PICK".equals(line.action()) || "PICK_PALLET".equals(line.action())) { + pickedCount = "PICK_PALLET".equals(line.action()) ? count(item.getPlannedPackageCount()) : request.pickedPackageCount(); changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId()); } else if ("PARTIAL".equals(line.action())) { pickedCount = request.finalPickedPackageCount(); @@ -1222,7 +1269,7 @@ public class LoadingPickService { requireNoTaskOperation(itemOperationId); if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交"); String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version() - + '|' + pickedCount + '|' + trim(request.reason())); + + '|' + pickedCount + '|' + trim(request.reason()) + '|' + request.palletNo()); insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount, trim(request.reason()), operatorId, operatorName); } @@ -1373,7 +1420,7 @@ public class LoadingPickService { canonical.append('|').append(item.itemId()).append('|').append(trim(item.action())) .append('|').append(item.pickedPackageCount()).append('|') .append(item.finalPickedPackageCount()).append('|') - .append(item.version()).append('|').append(trim(item.reason())); + .append(item.version()).append('|').append(trim(item.reason())).append('|').append(item.palletNo()); }); return hash(canonical.toString()); } 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 495956511..2678da454 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -201,13 +201,17 @@ components: items: {$ref: '#/components/schemas/PickItem'} PickItem: type: object - required: [id, loadingPlanItemId, tenantId, inboundId, inboundDetailId, boxCodes, inventoryResources, plannedPackageCount, pickedPackageCount, itemStatus, version] + required: [id, loadingPlanItemId, tenantId, inboundId, inboundDetailId, unitType, palletId, palletNo, indivisible, boxCodes, inventoryResources, plannedPackageCount, pickedPackageCount, itemStatus, version] properties: id: {type: integer, format: int64} loadingPlanItemId: {type: integer, format: int64} tenantId: {type: integer, format: int64, description: Hidden isolation key for subsequent PDA evidence calls} inboundId: {type: integer, format: int64} inboundDetailId: {type: integer, format: int64} + unitType: {type: string, enum: [PACKAGE, SEALED_PALLET], description: Authoritative loading plan unit; SEALED_PALLET must use the whole-pallet pick command} + palletId: {type: integer, format: int64, nullable: true, description: Dropship sealed pallet identity; not the weighing pallet in inventoryResources} + palletNo: {type: string, nullable: true, description: System-issued DP pallet code from the loading plan item} + indivisible: {type: boolean, description: Whole-pallet indivisibility from the loading plan item} orderNo: {type: string, nullable: true} inboundCode: {type: string, nullable: true} cargoName: {type: string, nullable: true} @@ -273,14 +277,16 @@ components: items: {$ref: '#/components/schemas/BatchItemRequest'} BatchItemRequest: type: object + description: PICK_PALLET submits every pickItem of the scanned DP pallet with its own version in the same atomic batch. No carton quantities or skip reason are accepted; the server verifies the complete SEALED pallet. Scanning only changes the device draft. required: [itemId, action, version] properties: itemId: {type: integer, format: int64, minimum: 1} - action: {type: string, enum: [PICK, PARTIAL, SKIP]} + action: {type: string, enum: [PICK, PARTIAL, SKIP, PICK_PALLET]} pickedPackageCount: {type: integer, minimum: 1, description: Incremental count required by PICK} finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL} version: {type: integer, minimum: 0} reason: {type: string, maxLength: 500, description: Required by SKIP} + palletNo: {type: string, nullable: true, pattern: '^DP-[0-9]{6}-[0-9]{4}$', description: Required only by PICK_PALLET; ordinary carton actions must omit it} ReviewRequest: type: object required: [expectedTaskVersion, operationId] @@ -313,7 +319,7 @@ components: remark: {type: string, maxLength: 500} InventorySnapshot: type: object - required: [taskId, loadingPlanId, containerBookingId, cabinetNo, taskVersion, warehouseId, capturedAt, snapshotToken, inbounds] + required: [taskId, loadingPlanId, containerBookingId, cabinetNo, taskVersion, warehouseId, capturedAt, snapshotToken, inbounds, units] properties: taskId: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64} @@ -327,6 +333,21 @@ components: inbounds: type: array items: {$ref: '#/components/schemas/InboundInventory'} + units: + type: array + description: Authoritative plan units ordered by pickItemId and included in snapshotToken; one inbound may contain multiple sealed pallets + items: {$ref: '#/components/schemas/InventoryUnit'} + InventoryUnit: + type: object + required: [pickItemId, loadingPlanItemId, inboundId, unitType, palletId, palletNo, indivisible] + properties: + pickItemId: {type: integer, format: int64} + loadingPlanItemId: {type: integer, format: int64} + inboundId: {type: integer, format: int64} + unitType: {type: string, enum: [PACKAGE, SEALED_PALLET]} + palletId: {type: integer, format: int64, nullable: true} + palletNo: {type: string, nullable: true} + indivisible: {type: boolean} InboundInventory: type: object required: [inboundId, availablePackageCount, resources] 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 7401e6d91..089336d9d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -65,7 +65,12 @@ class PdaConvergenceOpenApiContractTest { .contains("loadingPlanId:", "containerBookingId:") .doesNotContain("containerNo:"); assertThat(batchItemRequest) + .contains("PICK_PALLET", "palletNo:", "every pickItem", "No carton quantities") .doesNotContain("releaseMode:", "reservationIds:", "locationIds:"); + String pickItem = yaml.substring(yaml.indexOf(" PickItem:"), yaml.indexOf(" PendingTask:")); + assertThat(pickItem).contains("unitType:", "palletId:", "palletNo:", "indivisible:", "SEALED_PALLET"); + String inventoryUnits = yaml.substring(yaml.indexOf(" InventorySnapshot:"), yaml.indexOf(" InboundInventory:")); + assertThat(inventoryUnits).contains("units:", "InventoryUnit:", "pickItemId:", "loadingPlanItemId:", "SEALED_PALLET", "indivisible:"); assertThat(inventoryResource) .contains("blockId:", "slotNo:", "reservationId:", "weightResultId:", "slotStatus:") .doesNotContain("occupancyId:"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index 591eeefcc..ae16cb39a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -10,6 +10,20 @@ import java.lang.reflect.Modifier; import static org.assertj.core.api.Assertions.assertThat; class LoadingPickPdaTaskSqlContractTest { + @Test + void pickingUnitSnapshotsComeFromTheSameAuthoritativeLoadingPlanInEveryReadPath() throws Exception { + for (var method : LoadingPickItemMapper.class.getDeclaredMethods()) { + if (!java.util.Set.of("selectTaskItemsWithPlan", "selectTaskItemsWithAccount", "selectTaskItemWithAccount") + .contains(method.getName())) continue; + String sql = method.getAnnotation(Select.class).value()[0]; + assertThat(sql).contains("plan_item.unit_type", "plan_item.pallet_id", "plan_item.pallet_no", "plan_item.indivisible") + .contains("plan_item.id = pick_item.loading_plan_item_id"); + assertThat(sql).contains(method.getName().equals("selectTaskItemsWithPlan") + ? "plan_item.loading_plan_id = task.loading_plan_id" : "plan.id = pick_task.loading_plan_id"); + } + assertThat(LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithPlan", long.class)).isNotNull(); + } + @Test void queryRowsAreConcreteBeansThatMyBatisCanInstantiate() throws Exception { assertThat(LoadingPickTaskMapper.PendingTaskRow.class.isInterface()).isFalse(); 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 0ce9dc9f0..654345158 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -462,8 +462,13 @@ class LoadingPickServiceTest { @Test void taskDetailReturnsServerInboundAndLoadingVersions() { LoadingPickTaskEntity task = task(); + LoadingPickItemEntity sealedPallet = item(); + sealedPallet.setUnitType("SEALED_PALLET"); + sealedPallet.setPalletId(801L); + sealedPallet.setPalletNo("DP-260909-0001"); + sealedPallet.setIndivisible(true); when(repository.task(20L)).thenReturn(task); - when(repository.items(20L, 101L)).thenReturn(List.of(item())); + when(repository.items(20L, 101L)).thenReturn(List.of(sealedPallet)); when(repository.cartonLabels(20L)).thenReturn(List.of()); when(repository.assignment(20L)).thenReturn(null); when(repository.review(20L)).thenReturn(null); @@ -483,6 +488,8 @@ class LoadingPickServiceTest { assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); assertThat(result.mergedIntoContainerBookingId()).isNull(); + assertThat(result.items().get(0)).extracting("unitType", "palletId", "palletNo", "indivisible") + .containsExactly("SEALED_PALLET", 801L, "DP-260909-0001", true); assertThat(result.items().get(0).inventoryResources()).singleElement().satisfies(resource -> { assertThat(resource.blockId()).isEqualTo(71L); assertThat(resource.slotNo()).isEqualTo(1); @@ -1200,14 +1207,27 @@ class LoadingPickServiceTest { when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> List.of(allocation(invocation.getArgument(1), 2))); var snapshot = service.inventorySnapshot(20L, 101L); + assertThat(snapshot.units()).hasSize(2); + picked.setUnitType("SEALED_PALLET"); + picked.setPalletId(801L); + picked.setPalletNo("DP-260909-0001"); + picked.setIndivisible(true); + var palletSnapshot = service.inventorySnapshot(20L, 101L); + assertThat(palletSnapshot.units().get(0)).extracting("pickItemId", "unitType", "palletId", "palletNo", "indivisible") + .containsExactly(30L, "SEALED_PALLET", 801L, "DP-260909-0001", true); + assertThat(palletSnapshot.snapshotToken()).isNotEqualTo(snapshot.snapshotToken()); + picked.setUnitType(null); + picked.setPalletId(null); + picked.setPalletNo(null); + picked.setIndivisible(null); String snapshotToken = snapshot.snapshotToken(); assertThat(snapshot.inbounds()).hasSize(2); assertThat(snapshot.inbounds().get(0).resources().get(0)) .extracting("blockId", "slotNo", "reservationId", "locationId", "availablePackageCount") .containsExactly(71L, 1, 72L, 74L, 2); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( - new BatchItemRequest(30L, "PICK", 2, null, 0, null), - new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton"))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null), + new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton", null))); service.batch(20L, "batch-1", request, 9L, "operator"); service.batch(20L, "batch-1", request, 9L, "operator"); @@ -1248,7 +1268,7 @@ class LoadingPickServiceTest { List.of(allocation(invocation.getArgument(1), 100))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( - new BatchItemRequest(30L, "PICK", 100, null, 0, null))); + new BatchItemRequest(30L, "PICK", 100, null, 0, null, null))); service.batch(20L, "batch-reset-plan", request, 9L, "operator"); @@ -1274,8 +1294,8 @@ class LoadingPickServiceTest { when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( - new BatchItemRequest(30L, "PICK", 2, null, 0, null), - new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null), + new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null, null))); assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator")) .isInstanceOf(BizException.class); @@ -1286,6 +1306,87 @@ class LoadingPickServiceTest { verify(settlementService, never()).settle(20L, 9L, "operator"); } + private List prepareWholePalletBatch() { + var first = item(); + var second = item(); + second.setId(31L); + second.setLoadingPlanItemId(41L); + second.setInboundDetailId(61L); + var items = List.of(first, second); + for (var item : items) { + item.setUnitType("SEALED_PALLET"); item.setPalletId(801L); item.setPalletNo("DP-260909-0001"); item.setIndivisible(true); + } + when(repository.task(20L)).thenReturn(task()); + 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); + when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(1L, 50L)).thenReturn(List.of(allocation(50L, 4))); + when(dropshipPallets.sealedPalletLines(801L)).thenReturn(items.stream().map(item -> { + var line = new DropshipPalletizationMapper.PalletLine(); + line.setPalletId(801L); line.setPalletNo("DP-260909-0001"); line.setPalletStatus("SEALED"); + line.setWarehouseId(101L); line.setInboundId(50L); line.setInboundDetailId(item.getInboundDetailId()); line.setCartonCount(2); + return line; + }).toList()); + return items; + } + + @Test + void batchPicksEveryLineOfSealedPalletOnceAndReplaysWithoutSecondWrite() { + prepareWholePalletBatch(); + AtomicReference operation = new AtomicReference<>(); + when(repository.operation("batch-pallet")).thenAnswer(invocation -> operation.get()); + doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(LoadingPickOperationEntity.class)); + when(repository.pick(anyLong(), eq(2), eq(0), eq(101L))).thenReturn(1); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.touchTask(20L, 0)).thenReturn(1); + var snapshot = service.inventorySnapshot(20L, 101L); + assertThat(snapshot.units()).hasSize(2).allSatisfy(unit -> assertThat(unit.palletNo()).isEqualTo("DP-260909-0001")); + var request = new BatchRequest(101L, 10L, 99L, 0, snapshot.snapshotToken(), List.of( + new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001"), + new BatchItemRequest(31L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001"))); + service.batch(20L, "batch-pallet", request, 9L, "operator"); + service.batch(20L, "batch-pallet", request, 9L, "operator"); + verify(repository, times(1)).pick(30L, 2, 0, 101L); + verify(repository, times(1)).pick(31L, 2, 0, 101L); + verify(repository, times(1)).touchTask(20L, 0); + verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator"); + assertThatThrownBy(() -> service.batch(20L, "batch-pallet", new BatchRequest(101L, 10L, 99L, 0, + snapshot.snapshotToken(), List.of(new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0999"))), 9L, "operator")) + .isInstanceOf(BizException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"missing-row", "ordinary", "partial", "skip", "quantity", "wrong-code", "unsealed", "wrong-warehouse", "physical-short", "stale-item", "stale-task", "stale-snapshot"}) + void rejectsInvalidWholePalletBatchBeforeAnyWrite(String scenario) { + var items = prepareWholePalletBatch(); + String snapshot = service.inventorySnapshot(20L, 101L).snapshotToken(); + var lines = new java.util.ArrayList<>(List.of( + new BatchItemRequest(30L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001"), + new BatchItemRequest(31L, "PICK_PALLET", null, null, 0, null, "DP-260909-0001"))); + switch (scenario) { + case "missing-row" -> lines.remove(1); + case "ordinary" -> lines.set(1, new BatchItemRequest(31L, "PICK", 2, null, 0, null, null)); + case "partial" -> lines.set(1, new BatchItemRequest(31L, "PARTIAL", null, 1, 0, null, null)); + case "skip" -> lines.set(1, new BatchItemRequest(31L, "SKIP", null, null, 0, "skip", null)); + case "quantity" -> lines.set(1, new BatchItemRequest(31L, "PICK_PALLET", 2, null, 0, null, "DP-260909-0001")); + case "wrong-code" -> lines.set(1, new BatchItemRequest(31L, "PICK_PALLET", null, null, 0, null, "DP-260909-0999")); + case "unsealed" -> dropshipPallets.sealedPalletLines(801L).get(0).setPalletStatus("OPEN"); + case "wrong-warehouse" -> dropshipPallets.sealedPalletLines(801L).get(0).setWarehouseId(999L); + case "physical-short" -> dropshipPallets.sealedPalletLines(801L).get(0).setCartonCount(1); + case "stale-item" -> items.get(1).setVersion(9); + case "stale-snapshot" -> snapshot = "0".repeat(64); + } + var request = new BatchRequest(101L, 10L, 99L, "stale-task".equals(scenario) ? 9 : 0, snapshot, lines); + assertThatThrownBy(() -> service.batch(20L, "invalid-pallet", request, 9L, "operator")).isInstanceOf(BizException.class); + verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong()); + verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong()); + verify(repository, never()).skip(anyLong(), anyInt(), anyLong()); + verify(repository, never()).touchTask(anyLong(), anyInt()); + } + @Test void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() { LoadingPickTaskEntity task = task(); @@ -1299,7 +1400,7 @@ class LoadingPickServiceTest { when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> List.of(allocation(invocation.getArgument(1), 2))); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( - new BatchItemRequest(30L, "PICK", 2, null, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null))); assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) .isInstanceOf(BizException.class) @@ -1317,7 +1418,7 @@ class LoadingPickServiceTest { task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( - new BatchItemRequest(30L, "PICK", 2, null, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) .isInstanceOf(BizException.class); @@ -1343,7 +1444,7 @@ class LoadingPickServiceTest { when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> List.of(allocation(invocation.getArgument(1), 2))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); - BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null); + BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null, null); assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest( 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) @@ -1376,7 +1477,7 @@ class LoadingPickServiceTest { BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, "stale-token", List.of( - new BatchItemRequest(30L, "PICK", 2, null, 0, null))); + new BatchItemRequest(30L, "PICK", 2, null, 0, null, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest, 9L, "operator")).isInstanceOf(BizException.class); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 172f9ef1a..796372af5 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -144,6 +144,8 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s `remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查 plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当前已分配数量反推订单总箱数。 +`phase3-loading` 复用本轮一件代发 fixture,核对 task items 与 inventory units 的 `unitType=SEALED_PALLET/palletId/palletNo/indivisible`,整托统一通过 `/batch` 提交 `PICK_PALLET + palletNo`。合法整托行混入非法普通行必须整批零写;普通 `PICK/PARTIAL/SKIP` 不得绕过整托限制。同参同键重放不得增加版本或审计,同键更换托盘码必须冲突。以上接入现有全量链,不建立独立 HTTP 入口。 + `phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 部署后需要定向验证该完整链时,模块顺序固定为: diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c01e864d5..6288d55f0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -701,6 +701,21 @@ export async function run(context) { inventoryBlockIdentityVerified, `inbounds=${inventoryBlockSnapshots.length}, blocks=${inventoryBlockSnapshots.map((entry) => entry.resources[0]?.blockId).join(',')}`, OPTIONS) + if (dropshipAssignment) { + const palletUnits = Array.isArray(inventory?.units) ? inventory.units : [] + const taskUnitsValid = canonicalPickItems.every((item) => item.unitType === 'SEALED_PALLET' + && id(item.palletId) === id(candidate.palletId) && item.palletNo === candidate.palletNo + && /^DP-[0-9]{6}-[0-9]{4}$/.test(item.palletNo) && item.indivisible === true) + const inventoryUnitsValid = canonicalPickItems.every((item) => palletUnits.some((unit) => + id(unit.pickItemId) === id(item.id) && id(unit.loadingPlanItemId) === id(item.loadingPlanItemId) + && id(unit.inboundId) === id(item.inboundId) && unit.unitType === 'SEALED_PALLET' + && id(unit.palletId) === id(item.palletId) && unit.palletNo === item.palletNo && unit.indivisible === true)) + context.assert(MODULE, 'PDA task与inventory明确返回SEALED_PALLET及DP整托四字段', inventoryResponse, + taskUnitsValid && inventoryUnitsValid, + `taskUnits=${canonicalPickItems.length}, inventoryUnits=${palletUnits.length}`, OPTIONS) + if (!taskUnitsValid || !inventoryUnitsValid) return + } + const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128) @@ -715,8 +730,10 @@ export async function run(context) { expectedVersion: Number(claimed?.version), inventorySnapshotToken: inventory.snapshotToken, items: canonicalPickItems.map((item) => ({ - itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), - version: Number(item.version), + itemId: id(item.id), version: Number(item.version), + ...(dropshipWholePallet + ? { action: 'PICK_PALLET', palletNo: item.palletNo } + : { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }), })), } @@ -754,6 +771,30 @@ export async function run(context) { && operationsSignature(afterInvalidOperations) === operationsSignature(beforeInvalidOperations), `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) + if (dropshipWholePallet) { + for (const action of ['PICK', 'PARTIAL', 'SKIP']) { + const bypass = await request(context, 'POST', batchPath, { + ...batchPayload, + items: canonicalPickItems.map((item) => ({ itemId: id(item.id), version: Number(item.version), action, + ...(action === 'PICK' ? { pickedPackageCount: Number(item.plannedPackageCount) } + : action === 'PARTIAL' ? { finalPickedPackageCount: 1, reason: '整托不可部分拣货' } + : { reason: '整托不可普通跳过' }), + })), + }, { 'Idempotency-Key': `${context.marker}-pallet-bypass-${action}`.slice(0, 128) }) + context.assert(MODULE, `整托不能通过普通${action}动作绕过`, bypass, + bypass.http === 200 && codeOf(bypass) === 'A0409', `code=${codeOf(bypass)}`, OPTIONS) + } + const afterBypassResponse = await context.request('GET', taskDetailPath) + const afterBypass = context.apiSuccess(MODULE, '整托绕过拒绝后任务回查', afterBypassResponse, '', OPTIONS) + const afterBypassOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const afterBypassOperations = context.apiSuccess(MODULE, '整托绕过拒绝后审计回查', afterBypassOperationsResponse, '', OPTIONS) + context.assert(MODULE, '整托绕过失败后任务明细审计零写入', afterBypassResponse, + Number(afterBypass?.version) === Number(beforeInvalidTask?.version) + && taskItemsSignature(afterBypass) === taskItemsSignature(beforeInvalidTask) + && operationsSignature(afterBypassOperations) === operationsSignature(beforeInvalidOperations), + `taskVersion=${afterBypass?.version ?? 'missing'}`, OPTIONS) + } + const merchantLogin = await context.request('POST', '/api/auth/login', { loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') @@ -803,12 +844,8 @@ export async function run(context) { id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId), `assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS) - const canonicalPickPath = dropshipWholePallet - ? `${pdaBase}/tasks/${canonicalTaskId}/pallets/pick` - : batchPath - const canonicalPickPayload = dropshipWholePallet - ? { warehouseId, palletNo: candidate.palletNo, expectedTaskVersion: Number(claimed?.version), operationId: batchKey } - : batchPayload + const canonicalPickPath = batchPath + const canonicalPickPayload = batchPayload const canonicalPick = await request(context, 'POST', canonicalPickPath, canonicalPickPayload, { 'Idempotency-Key': batchKey }) const pickedCanonical = context.apiSuccess(MODULE, @@ -831,15 +868,25 @@ export async function run(context) { atomicBatchReplayVerified, `HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS) + if (dropshipWholePallet) { + const palletHashConflict = await request(context, 'POST', batchPath, { + ...batchPayload, items: batchPayload.items.map((item) => ({ ...item, + palletNo: item.palletNo === 'DP-991231-9999' ? 'DP-991231-9998' : 'DP-991231-9999' })), + }, { 'Idempotency-Key': batchKey }) + context.assert(MODULE, '整托batch同键改托盘码必须冲突', palletHashConflict, + palletHashConflict.http === 200 && codeOf(palletHashConflict) === 'A0409', + `code=${codeOf(palletHashConflict)}`, OPTIONS) + } + const preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前拣货审计', preFinalOperationsResponse, '', OPTIONS) - const canonicalPickAction = dropshipWholePallet ? 'PICK_PALLET' : 'BATCH' + const canonicalPickAction = 'BATCH' context.assert(MODULE, '最终确认前仅保留领取和拣货审计且无 COMPLETE_LOADING', preFinalOperationsResponse, Array.isArray(preFinalOperations) && preFinalOperations.some((item) => item.actionType === 'CLAIM' && id(item.operatorId) && Boolean(item.createdAt)) - && preFinalOperations.some((item) => item.actionType === canonicalPickAction - && id(item.operatorId) && Boolean(item.createdAt)) + && preFinalOperations.filter((item) => item.actionType === canonicalPickAction + && item.operationId === batchKey && id(item.operatorId) && Boolean(item.createdAt)).length === 1 && !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING') && !preFinalOperations.some((item) => item.actionType === 'REVIEW'), `actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index cf6bff308..04309fb17 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -104,9 +104,9 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(loading, /canonicalPickItems\.map/) assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/) assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/) - assert.match(loading, /tasks\/\$\{canonicalTaskId\}\/pallets\/pick/) - assert.match(loading, /palletNo: candidate\.palletNo/) - assert.match(loading, /canonicalPickAction = dropshipWholePallet \? 'PICK_PALLET' : 'BATCH'/) + assert.match(loading, /const canonicalPickPath = batchPath/) + assert.match(loading, /action: 'PICK_PALLET', palletNo: item\.palletNo/) + assert.match(loading, /const canonicalPickAction = 'BATCH'/) assert.match(loading, /一件代发整托生产者不执行可拆分动态增量场景/) assert.match(loading, /!dropshipWholePallet && Array\.isArray\(incrementalFixture\?\.skuCodes\)/) assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) @@ -148,3 +148,22 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.doesNotMatch(entry, /awaitingVariants\.length === 2/) assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/) }) + + +test('full phase3 chain validates sealed pallet query metadata and atomic batch protection', () => { + const loading = read('phase3-loading.mjs') + assert.match(loading, /canonicalPickItems\.every\(\(item\) => item\.unitType === 'SEALED_PALLET'/) + assert.match(loading, /unit\.unitType === 'SEALED_PALLET'/) + for (const field of ['pickItemId', 'loadingPlanItemId', 'inboundId', 'palletId', 'palletNo', 'indivisible']) { + assert.ok(loading.includes(`unit.${field}`), `inventory units must verify ${field}`) + } + assert.match(loading, /if \(!taskUnitsValid \|\| !inventoryUnitsValid\) return/) + assert.match(loading, /items: \[\.\.\.batchPayload\.items, \{/) + assert.match(loading, /taskItemsSignature\(unchangedAfterInvalid\) === taskItemsSignature\(beforeInvalidTask\)/) + assert.match(loading, /operationsSignature\(afterInvalidOperations\) === operationsSignature\(beforeInvalidOperations\)/) + assert.match(loading, /for \(const action of \['PICK', 'PARTIAL', 'SKIP'\]\)/) + assert.match(loading, /operationsSignature\(afterBypassOperations\) === operationsSignature\(beforeInvalidOperations\)/) + assert.match(loading, /整托batch同键改托盘码必须冲突/) + assert.match(loading, /item\.operationId === batchKey[\s\S]*?\.length === 1/) + assert.doesNotMatch(loading, /tasks\/\$\{canonicalTaskId\}\/pallets\/pick/) +})