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( public record BatchItemRequest(
@NotNull @Min(1) Long itemId, @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 pickedPackageCount,
@Min(1) Integer finalPickedPackageCount, @Min(1) Integer finalPickedPackageCount,
@NotNull @Min(0) Integer version, @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, 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, String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
List<InventoryResource> inventoryResources, List<InventoryResource> inventoryResources,
Integer plannedPackageCount, Integer pickedPackageCount, Integer plannedPackageCount, Integer pickedPackageCount,
@@ -152,6 +154,10 @@ public final class LoadingPickDtos {
public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId, public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId,
String cabinetNo, Integer taskVersion, String currentContainerNo, String cabinetNo, Integer taskVersion, String currentContainerNo,
Long warehouseId, LocalDateTime capturedAt, String snapshotToken, 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.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version; import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter; import lombok.Getter;
@@ -18,6 +19,10 @@ public class LoadingPickItemEntity {
private Long id; private Long id;
private Long pickTaskId; private Long pickTaskId;
private Long loadingPlanItemId; 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 inboundId;
private Long inboundDetailId; private Long inboundDetailId;
private Integer plannedPackageCount; private Integer plannedPackageCount;
@@ -11,6 +11,17 @@ import java.util.List;
@Mapper @Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> { 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("""
SELECT pick_item.id AS pickItemId, plan_item.account_id AS tenantId, plan_item.order_no AS orderNo, 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, 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) {} record InboundOwner(Long tenantId, Long inboundId) {}
@Select(""" @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 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_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_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); @Param("warehouseId") long warehouseId);
@Select(""" @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 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_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_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) { public List<LoadingPickItemEntity> items(long taskId) {
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>() return items.selectTaskItemsWithPlan(taskId);
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickItemEntity::getId));
} }
public List<CartonLabelRow> cartonLabels(long 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.InventoryResource;
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory; import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; 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.dto.LoadingPickDtos.PendingTask;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
@@ -484,11 +485,11 @@ public class LoadingPickService {
List<BatchLine> lines = validateBatchLines(task, request); List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines); 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) { for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName); 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); startPickingIfNeeded(task, operatorId, operatorName);
} }
if (!repository.allTaskItemsResolved(taskId)) { if (!repository.allTaskItemsResolved(taskId)) {
@@ -968,7 +969,8 @@ public class LoadingPickService {
Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId(); Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId();
return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId, return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId,
item.getInboundId(), 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(), first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(),
cartons.stream().map(CartonLabelRow::getBoxCode).toList(), cartons.stream().map(CartonLabelRow::getBoxCode).toList(),
inventoryByInbound.getOrDefault(item.getInboundId(), List.of()), item.getPlannedPackageCount(), inventoryByInbound.getOrDefault(item.getInboundId(), List.of()), item.getPlannedPackageCount(),
@@ -1045,7 +1047,22 @@ public class LoadingPickService {
throw conflict("PDA明细版本已变化,请刷新后重试"); throw conflict("PDA明细版本已变化,请刷新后重试");
} }
String action = requestItem.action().trim().toUpperCase(); 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); requireNoUnexpectedBatchFields(requestItem, false, true);
int current = count(item.getPickedPackageCount()); int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount()); int planned = count(item.getPlannedPackageCount());
@@ -1075,14 +1092,35 @@ public class LoadingPickService {
throw conflict("同一入库单已有拣货记录,不能在整批中跳拣"); throw conflict("同一入库单已有拣货记录,不能在整批中跳拣");
} }
} else { } 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())); result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
} }
validateWholePallets(result, scopedItems, request.warehouseId());
result.sort(Comparator.comparing(line -> line.item().getId())); result.sort(Comparator.comparing(line -> line.item().getId()));
return result; 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) { private List<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId()); List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId); List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId);
@@ -1102,11 +1140,14 @@ public class LoadingPickService {
int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum(); int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum();
return new InboundInventory(inboundId, available, resources); return new InboundInventory(inboundId, available, resources);
}).toList(); }).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()); var booking = smartLoadingRepository.booking(task.getContainerBookingId());
return new InventorySnapshot(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(), return new InventorySnapshot(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(),
task.getVersion(), booking == null ? null : booking.getContainerNo(), warehouseId, task.getVersion(), booking == null ? null : booking.getContainerNo(), warehouseId,
LocalDateTime.now(), token, inbounds); LocalDateTime.now(), token, inbounds, units);
} }
private Map<Long, Long> tenantByInbound(long taskId) { private Map<Long, Long> tenantByInbound(long taskId) {
@@ -1160,7 +1201,7 @@ public class LoadingPickService {
} }
private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId, 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('|') StringBuilder canonical = new StringBuilder("INVENTORY|").append(task.getId()).append('|')
.append(task.getVersion()).append('|').append(warehouseId); .append(task.getVersion()).append('|').append(warehouseId);
for (InboundInventory inbound : inbounds) { for (InboundInventory inbound : inbounds) {
@@ -1171,6 +1212,11 @@ public class LoadingPickService {
.append('|').append(resource.availablePackageCount()); .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()); return hash(canonical.toString());
} }
@@ -1191,6 +1237,7 @@ public class LoadingPickService {
int picked = line == null ? count(item.getPickedPackageCount()) int picked = line == null ? count(item.getPickedPackageCount())
: "PICK".equals(line.action()) : "PICK".equals(line.action())
? count(item.getPickedPackageCount()) + line.request().pickedPackageCount() ? count(item.getPickedPackageCount()) + line.request().pickedPackageCount()
: "PICK_PALLET".equals(line.action()) ? count(item.getPlannedPackageCount())
: "PARTIAL".equals(line.action()) ? line.request().finalPickedPackageCount() : 0; : "PARTIAL".equals(line.action()) ? line.request().finalPickedPackageCount() : 0;
requestedByInbound.merge(item.getInboundId(), picked, Integer::sum); requestedByInbound.merge(item.getInboundId(), picked, Integer::sum);
} }
@@ -1207,8 +1254,8 @@ public class LoadingPickService {
BatchItemRequest request = line.request(); BatchItemRequest request = line.request();
int changed; int changed;
int pickedCount; int pickedCount;
if ("PICK".equals(line.action())) { if ("PICK".equals(line.action()) || "PICK_PALLET".equals(line.action())) {
pickedCount = request.pickedPackageCount(); pickedCount = "PICK_PALLET".equals(line.action()) ? count(item.getPlannedPackageCount()) : request.pickedPackageCount();
changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId()); changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId());
} else if ("PARTIAL".equals(line.action())) { } else if ("PARTIAL".equals(line.action())) {
pickedCount = request.finalPickedPackageCount(); pickedCount = request.finalPickedPackageCount();
@@ -1222,7 +1269,7 @@ public class LoadingPickService {
requireNoTaskOperation(itemOperationId); requireNoTaskOperation(itemOperationId);
if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交"); if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交");
String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version() 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, insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount,
trim(request.reason()), operatorId, operatorName); trim(request.reason()), operatorId, operatorName);
} }
@@ -1373,7 +1420,7 @@ public class LoadingPickService {
canonical.append('|').append(item.itemId()).append('|').append(trim(item.action())) canonical.append('|').append(item.itemId()).append('|').append(trim(item.action()))
.append('|').append(item.pickedPackageCount()).append('|') .append('|').append(item.pickedPackageCount()).append('|')
.append(item.finalPickedPackageCount()).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()); return hash(canonical.toString());
} }
@@ -201,13 +201,17 @@ components:
items: {$ref: '#/components/schemas/PickItem'} items: {$ref: '#/components/schemas/PickItem'}
PickItem: PickItem:
type: object 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: properties:
id: {type: integer, format: int64} id: {type: integer, format: int64}
loadingPlanItemId: {type: integer, format: int64} loadingPlanItemId: {type: integer, format: int64}
tenantId: {type: integer, format: int64, description: Hidden isolation key for subsequent PDA evidence calls} tenantId: {type: integer, format: int64, description: Hidden isolation key for subsequent PDA evidence calls}
inboundId: {type: integer, format: int64} inboundId: {type: integer, format: int64}
inboundDetailId: {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} orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true} inboundCode: {type: string, nullable: true}
cargoName: {type: string, nullable: true} cargoName: {type: string, nullable: true}
@@ -273,14 +277,16 @@ components:
items: {$ref: '#/components/schemas/BatchItemRequest'} items: {$ref: '#/components/schemas/BatchItemRequest'}
BatchItemRequest: BatchItemRequest:
type: object 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] required: [itemId, action, version]
properties: properties:
itemId: {type: integer, format: int64, minimum: 1} 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} pickedPackageCount: {type: integer, minimum: 1, description: Incremental count required by PICK}
finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL} finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL}
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500, description: Required by SKIP} 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: ReviewRequest:
type: object type: object
required: [expectedTaskVersion, operationId] required: [expectedTaskVersion, operationId]
@@ -313,7 +319,7 @@ components:
remark: {type: string, maxLength: 500} remark: {type: string, maxLength: 500}
InventorySnapshot: InventorySnapshot:
type: object type: object
required: [taskId, loadingPlanId, containerBookingId, cabinetNo, taskVersion, warehouseId, capturedAt, snapshotToken, inbounds] required: [taskId, loadingPlanId, containerBookingId, cabinetNo, taskVersion, warehouseId, capturedAt, snapshotToken, inbounds, units]
properties: properties:
taskId: {type: integer, format: int64} taskId: {type: integer, format: int64}
loadingPlanId: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64}
@@ -327,6 +333,21 @@ components:
inbounds: inbounds:
type: array type: array
items: {$ref: '#/components/schemas/InboundInventory'} 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: InboundInventory:
type: object type: object
required: [inboundId, availablePackageCount, resources] required: [inboundId, availablePackageCount, resources]
@@ -65,7 +65,12 @@ class PdaConvergenceOpenApiContractTest {
.contains("loadingPlanId:", "containerBookingId:") .contains("loadingPlanId:", "containerBookingId:")
.doesNotContain("containerNo:"); .doesNotContain("containerNo:");
assertThat(batchItemRequest) assertThat(batchItemRequest)
.contains("PICK_PALLET", "palletNo:", "every pickItem", "No carton quantities")
.doesNotContain("releaseMode:", "reservationIds:", "locationIds:"); .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) assertThat(inventoryResource)
.contains("blockId:", "slotNo:", "reservationId:", "weightResultId:", "slotStatus:") .contains("blockId:", "slotNo:", "reservationId:", "weightResultId:", "slotStatus:")
.doesNotContain("occupancyId:"); .doesNotContain("occupancyId:");
@@ -10,6 +10,20 @@ import java.lang.reflect.Modifier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class LoadingPickPdaTaskSqlContractTest { 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 @Test
void queryRowsAreConcreteBeansThatMyBatisCanInstantiate() throws Exception { void queryRowsAreConcreteBeansThatMyBatisCanInstantiate() throws Exception {
assertThat(LoadingPickTaskMapper.PendingTaskRow.class.isInterface()).isFalse(); assertThat(LoadingPickTaskMapper.PendingTaskRow.class.isInterface()).isFalse();
@@ -462,8 +462,13 @@ class LoadingPickServiceTest {
@Test @Test
void taskDetailReturnsServerInboundAndLoadingVersions() { void taskDetailReturnsServerInboundAndLoadingVersions() {
LoadingPickTaskEntity task = task(); 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.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.cartonLabels(20L)).thenReturn(List.of());
when(repository.assignment(20L)).thenReturn(null); when(repository.assignment(20L)).thenReturn(null);
when(repository.review(20L)).thenReturn(null); when(repository.review(20L)).thenReturn(null);
@@ -483,6 +488,8 @@ class LoadingPickServiceTest {
assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE"); assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull(); 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(result.items().get(0).inventoryResources()).singleElement().satisfies(resource -> {
assertThat(resource.blockId()).isEqualTo(71L); assertThat(resource.blockId()).isEqualTo(71L);
assertThat(resource.slotNo()).isEqualTo(1); assertThat(resource.slotNo()).isEqualTo(1);
@@ -1200,14 +1207,27 @@ class LoadingPickServiceTest {
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2))); List.of(allocation(invocation.getArgument(1), 2)));
var snapshot = service.inventorySnapshot(20L, 101L); 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(); String snapshotToken = snapshot.snapshotToken();
assertThat(snapshot.inbounds()).hasSize(2); assertThat(snapshot.inbounds()).hasSize(2);
assertThat(snapshot.inbounds().get(0).resources().get(0)) assertThat(snapshot.inbounds().get(0).resources().get(0))
.extracting("blockId", "slotNo", "reservationId", "locationId", "availablePackageCount") .extracting("blockId", "slotNo", "reservationId", "locationId", "availablePackageCount")
.containsExactly(71L, 1, 72L, 74L, 2); .containsExactly(71L, 1, 72L, 74L, 2);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null), new BatchItemRequest(30L, "PICK", 2, null, 0, null, null),
new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton"))); 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");
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))); List.of(allocation(invocation.getArgument(1), 100)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( 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"); 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)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null), new BatchItemRequest(30L, "PICK", 2, null, 0, null, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null))); new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator")) assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator"))
.isInstanceOf(BizException.class); .isInstanceOf(BizException.class);
@@ -1286,6 +1306,87 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settle(20L, 9L, "operator"); 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 @Test
void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() { void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() {
LoadingPickTaskEntity task = task(); LoadingPickTaskEntity task = task();
@@ -1299,7 +1400,7 @@ class LoadingPickServiceTest {
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2))); List.of(allocation(invocation.getArgument(1), 2)));
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( 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")) assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
@@ -1317,7 +1418,7 @@ class LoadingPickServiceTest {
task.setVersion(3); task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.taskForUpdate(20L)).thenReturn(task);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( 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")) assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class); .isInstanceOf(BizException.class);
@@ -1343,7 +1444,7 @@ class LoadingPickServiceTest {
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2))); List.of(allocation(invocation.getArgument(1), 2)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); 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( assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest(
101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator"))
@@ -1376,7 +1477,7 @@ class LoadingPickServiceTest {
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1,
"stale-token", List.of( "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, assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest,
9L, "operator")).isInstanceOf(BizException.class); 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`,并回查 `remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当前已分配数量反推订单总箱数。 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 失败阻断,不由脚本或前端改写状态。 `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, inventoryBlockIdentityVerified,
`inbounds=${inventoryBlockSnapshots.length}, blocks=${inventoryBlockSnapshots.map((entry) => entry.resources[0]?.blockId).join(',')}`, OPTIONS) `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 batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
const skipBatchKey = `${context.marker}-exception-skip-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), expectedVersion: Number(claimed?.version),
inventorySnapshotToken: inventory.snapshotToken, inventorySnapshotToken: inventory.snapshotToken,
items: canonicalPickItems.map((item) => ({ items: canonicalPickItems.map((item) => ({
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), itemId: id(item.id), version: Number(item.version),
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), && operationsSignature(afterInvalidOperations) === operationsSignature(beforeInvalidOperations),
`taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) `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', { const merchantLogin = await context.request('POST', '/api/auth/login', {
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '') }, '')
@@ -803,12 +844,8 @@ export async function run(context) {
id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId), id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId),
`assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS) `assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
const canonicalPickPath = dropshipWholePallet const canonicalPickPath = batchPath
? `${pdaBase}/tasks/${canonicalTaskId}/pallets/pick` const canonicalPickPayload = batchPayload
: batchPath
const canonicalPickPayload = dropshipWholePallet
? { warehouseId, palletNo: candidate.palletNo, expectedTaskVersion: Number(claimed?.version), operationId: batchKey }
: batchPayload
const canonicalPick = await request(context, 'POST', canonicalPickPath, canonicalPickPayload, const canonicalPick = await request(context, 'POST', canonicalPickPath, canonicalPickPayload,
{ 'Idempotency-Key': batchKey }) { 'Idempotency-Key': batchKey })
const pickedCanonical = context.apiSuccess(MODULE, const pickedCanonical = context.apiSuccess(MODULE,
@@ -831,15 +868,25 @@ export async function run(context) {
atomicBatchReplayVerified, atomicBatchReplayVerified,
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS) `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 preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前拣货审计', preFinalOperationsResponse, '', OPTIONS) const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前拣货审计', preFinalOperationsResponse, '', OPTIONS)
const canonicalPickAction = dropshipWholePallet ? 'PICK_PALLET' : 'BATCH' const canonicalPickAction = 'BATCH'
context.assert(MODULE, '最终确认前仅保留领取和拣货审计且无 COMPLETE_LOADING', preFinalOperationsResponse, context.assert(MODULE, '最终确认前仅保留领取和拣货审计且无 COMPLETE_LOADING', preFinalOperationsResponse,
Array.isArray(preFinalOperations) Array.isArray(preFinalOperations)
&& preFinalOperations.some((item) => item.actionType === 'CLAIM' && preFinalOperations.some((item) => item.actionType === 'CLAIM'
&& id(item.operatorId) && Boolean(item.createdAt)) && id(item.operatorId) && Boolean(item.createdAt))
&& preFinalOperations.some((item) => item.actionType === canonicalPickAction && preFinalOperations.filter((item) => item.actionType === canonicalPickAction
&& id(item.operatorId) && Boolean(item.createdAt)) && item.operationId === batchKey && id(item.operatorId) && Boolean(item.createdAt)).length === 1
&& !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING') && !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING')
&& !preFinalOperations.some((item) => item.actionType === 'REVIEW'), && !preFinalOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) `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, /canonicalPickItems\.map/)
assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/) assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/)
assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/) assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/)
assert.match(loading, /tasks\/\$\{canonicalTaskId\}\/pallets\/pick/) assert.match(loading, /const canonicalPickPath = batchPath/)
assert.match(loading, /palletNo: candidate\.palletNo/) assert.match(loading, /action: 'PICK_PALLET', palletNo: item\.palletNo/)
assert.match(loading, /canonicalPickAction = dropshipWholePallet \? 'PICK_PALLET' : 'BATCH'/) assert.match(loading, /const canonicalPickAction = 'BATCH'/)
assert.match(loading, /一件代发整托生产者不执行可拆分动态增量场景/) assert.match(loading, /一件代发整托生产者不执行可拆分动态增量场景/)
assert.match(loading, /!dropshipWholePallet && Array\.isArray\(incrementalFixture\?\.skuCodes\)/) assert.match(loading, /!dropshipWholePallet && Array\.isArray\(incrementalFixture\?\.skuCodes\)/)
assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) 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.doesNotMatch(entry, /awaitingVariants\.length === 2/)
assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/) 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/)
})