fix(firstleg): preserve sealed pallet identity in atomic PDA picking

This commit is contained in:
2026-09-09 12:33:31 +08:00
parent 44ac24a9e2
commit ac3ce3703e
12 changed files with 323 additions and 47 deletions
@@ -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<String> boxCodes,
List<InventoryResource> 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<InboundInventory> inbounds) {
List<InboundInventory> inbounds, List<InventoryUnit> units) {
}
public record InventoryUnit(Long pickItemId, Long loadingPlanItemId, Long inboundId,
String unitType, Long palletId, String palletNo, Boolean indivisible) {
}
}
@@ -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;
@@ -11,6 +11,17 @@ import java.util.List;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@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<LoadingPickItemEntity> 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<LoadingPickItemEntity>
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<LoadingPickItemEntity>
@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
@@ -105,9 +105,7 @@ public class LoadingPickRepository {
}
public List<LoadingPickItemEntity> items(long taskId) {
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickItemEntity::getId));
return items.selectTaskItemsWithPlan(taskId);
}
public List<CartonLabelRow> cartonLabels(long taskId) {
@@ -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<BatchLine> 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<BatchLine> lines, List<LoadingPickItemEntity> allItems, long warehouseId) {
Map<Long, List<BatchLine>> groups = lines.stream().filter(line -> "PICK_PALLET".equals(line.action()))
.collect(Collectors.groupingBy(line -> line.item().getPalletId()));
for (var entry : groups.entrySet()) {
List<LoadingPickItemEntity> 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<String, Integer> physicalCounts = new HashMap<>();
physical.forEach(line -> physicalCounts.merge(line.getInboundId() + ":" + line.getInboundDetailId(), count(line.getCartonCount()), Integer::sum));
Map<String, Integer> 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<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> 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<InventoryUnit> 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<Long, Long> tenantByInbound(long taskId) {
@@ -1160,7 +1201,7 @@ public class LoadingPickService {
}
private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId,
List<InboundInventory> inbounds) {
List<InboundInventory> inbounds, List<InventoryUnit> 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());
}
@@ -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]
@@ -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:");
@@ -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();
@@ -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<LoadingPickItemEntity> 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<LoadingPickOperationEntity> 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);
+2
View File
@@ -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 失败阻断,不由脚本或前端改写状态。
部署后需要定向验证该完整链时,模块顺序固定为:
@@ -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)
@@ -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/)
})