fix container customer snapshot schema migration

This commit is contained in:
2026-08-24 13:36:38 +08:00
committed by liuyihe
parent fe4eb0b593
commit b72dd54b80
23 changed files with 368 additions and 49 deletions
@@ -3,7 +3,7 @@ package com.baiyue.common.event;
import java.math.BigDecimal;
import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
@@ -71,7 +71,7 @@ public record FirstLegLoadingPlanCompletedV2(
}
}
/** 一键代发入仓单的托盘冻结快照。 */
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
@@ -4938,6 +4938,21 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='customer_id') THEN
ALTER TABLE fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL
COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='customer_name') THEN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
@@ -15390,11 +15405,17 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='ocean_booking_requirement_id') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='customer_id') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='customer_name') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE baiyue_firstleg.fl_container_booking c JOIN baiyue_firstleg.fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id
JOIN baiyue_firstleg.fl_ocean_booking b ON b.id=a.ocean_booking_id
SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name)
SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name),c.customer_id=COALESCE(c.customer_id,b.customer_id),c.customer_name=COALESCE(c.customer_name,b.customer_name)
WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL
OR c.channel_code IS NULL OR c.channel_name IS NULL;
OR c.channel_code IS NULL OR c.channel_name IS NULL OR c.customer_id IS NULL OR c.customer_name IS NULL;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN
ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id;
END IF;
@@ -38,7 +38,7 @@ public interface LoadingCompletedEventMapper {
""")
List<Row> rows(@Param("planId") long planId);
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
/** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */
@Select("""
SELECT i.account_id, i.inbound_id AS source_inbound_id,
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
@@ -89,8 +89,7 @@ public class LoadingCompletedOutboxService {
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
.toList();
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(first.getBusinessType())
? palletSnapshots(sorted, palletRows) : List.of();
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = palletSnapshots(sorted, palletRows);
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
@@ -99,7 +98,7 @@ public class LoadingCompletedOutboxService {
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
? pallets.size() : sumNullableInt(sorted, Row::getPalletCount), sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets);
}
@@ -132,6 +132,21 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='customer_id') THEN
ALTER TABLE fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL
COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='customer_name') THEN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
@@ -76,6 +76,19 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@Test
@DisplayName("存量货柜迁移补齐BaseMapper所需客户快照列")
void shouldMigrateContainerCustomerSnapshotColumns() throws Exception {
for (String sql : Set.of(migration(), unifiedMigration())) {
assertThat(sql)
.contains("ADD COLUMN customer_id BIGINT UNSIGNED NULL")
.contains("ADD COLUMN customer_name VARCHAR(128) NULL")
.contains("JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id")
.contains("container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id)")
.contains("container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)");
}
}
@Test
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
@@ -55,6 +55,7 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1");
assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@@ -118,6 +119,20 @@ class LoadingCompletedOutboxServiceTest {
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@Test void publishesOrdinaryRecommendedPalletNumbersForTailLegReuse() throws Exception {
Row value = row(11, 100, 101);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11, 100, 1, "FL-PLT-001", 1, 101, "SKU101", 1, 10, "BOX-POOL-1")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/pallets/0/palletNo").asText()).isEqualTo("FL-PLT-001");
}
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
+1 -1
View File
@@ -68,7 +68,7 @@
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
@@ -17,6 +17,7 @@ public record InboundPlanListResponse(
String fbaWarehouseCode,
String customerName,
Integer packageCount,
Integer pickedPackageCount,
BigDecimal volumeCbm,
Integer palletCount,
String status,
@@ -27,4 +28,17 @@ public record InboundPlanListResponse(
String dispatchStatus,
LocalDateTime createdAt
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
String warehouseId, String warehouseCode, String warehouseName,
String fbaWarehouseCode, String customerName, Integer packageCount,
BigDecimal volumeCbm, Integer palletCount, String status,
String appointmentNo, String appointmentStatus,
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String dispatchStatus, LocalDateTime createdAt) {
this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt);
}
}
@@ -14,10 +14,20 @@ public record InboundPlanResponse(
int lineCount,
int palletCount,
BigDecimal totalVolumeCbm,
int expectedPackageCount,
int pickedPackageCount,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
@@ -18,6 +18,7 @@ public interface InboundPlanOrderMapper {
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
@@ -30,7 +31,7 @@ public interface InboundPlanOrderMapper {
"AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
@@ -39,7 +39,9 @@ public interface InboundPlanQueryMapper {
"l.order_id orderId,l.inbound_code inboundCode,o.order_type orderType," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount,l.volume_cbm volumeCbm," +
"o.customer_name customerName,l.required_package_count packageCount," +
"COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," +
"l.volume_cbm volumeCbm," +
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
@@ -11,6 +11,7 @@ public record SourceOrderSnapshot(
String inboundOrderNo,
BigDecimal volumeCbm,
int packageCount,
int recommendedPalletCount,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
@@ -18,13 +19,23 @@ public record SourceOrderSnapshot(
int destinationCount,
int destinationMismatchCount
) {
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, null, destinationWarehouseId, destinationWarehouseCode,
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
}
@@ -108,6 +108,15 @@ public class InboundPdaController {
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
}
@PostMapping("/partial-complete")
public ApiResponse<PdaResponses.Task> partialComplete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId,
@Valid @RequestBody PdaRequests.ForceComplete request) {
requireOperate(user);
return ApiResponse.success(service.partialComplete(scope(user, customerId), planNo, request));
}
@PostMapping("/unload-photos")
public ApiResponse<PdaResponses.Task> uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@@ -20,8 +20,16 @@ public final class PdaRequests {
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, Long planPalletId, Integer packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {
/** Backward-compatible single-carton request with an explicitly selected pallet. */
public ScanCarton(String cartonMark, long planPalletId, String idempotencyKey,
String operatorId, String operatorName) {
this(cartonMark, planPalletId, 1, idempotencyKey, operatorId, operatorName);
}
}
public record ForceComplete(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
@@ -10,7 +10,16 @@ public final class PdaResponses {
public record Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {}
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount) {
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, 0, 0);
}
}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
@@ -29,5 +38,10 @@ public final class PdaResponses {
boolean sourcePalletScrapped) {}
public record Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {}
int palletActualPackageCount, String palletNo, int scannedPackageCount) {
public Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {
this(result, cartonMark, planPalletId, allocationId, palletActualPackageCount, null, 1);
}
}
}
@@ -73,6 +73,9 @@ public class InboundPdaWorkflowService {
}
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
LocalDateTime now = LocalDateTime.now();
if (task != null && terminal(task.getStatus())) {
throw conflict("PDA inbound task is already completed");
}
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId());
@@ -91,7 +94,7 @@ public class InboundPdaWorkflowService {
plan.setStatus("PDA_PENDING");
plan.setUpdatedAt(now);
repository.update(plan);
PdaResponses.Task result = task(task, planNo);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
return result;
}
@@ -132,7 +135,7 @@ public class InboundPdaWorkflowService {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = repository.task(customerId, plan.getId());
if (task == null) throw notFound("PDA task not found");
return task(task, planNo);
return taskResponse(task, plan, planNo);
}
@Transactional
@@ -220,39 +223,62 @@ public class InboundPdaWorkflowService {
if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
throw conflict("Carton mark has already been scanned; remove it before scanning again");
}
InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId());
long requestedPalletId = request.planPalletId() == null ? 0L : request.planPalletId();
InboundPlanPallet pallet = requestedPalletId > 0
? requirePallet(plan.getId(), requestedPalletId)
: autoPallet(plan, carton);
if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning");
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
InboundPlanLine line = carton.getLineId() == null
? repository.lineForCarton(plan.getId(), carton)
: requireLine(plan.getId(), carton.getLineId());
if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan");
if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) {
int requestedCount = request.packageCount() == null ? 1 : request.packageCount();
if (requestedCount < 1) throw conflict("Package count must be positive");
int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId());
if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count");
}
List<InboundPlanCarton> cartonsToScan = new ArrayList<>();
cartonsToScan.add(carton);
if (requestedCount > 1) {
repository.cartons(plan.getId()).stream()
.filter(candidate -> !Objects.equals(candidate.getId(), carton.getId()))
.filter(candidate -> Objects.equals(candidate.getLineId(), line.getId()))
.filter(candidate -> "UNASSIGNED".equals(candidate.getStatus()) || "EXPECTED".equals(candidate.getStatus()))
.filter(candidate -> sameWarehouse(candidate, pallet))
.limit(requestedCount - 1L).forEach(cartonsToScan::add);
if (cartonsToScan.size() != requestedCount) throw conflict("Not enough unscanned cartons for the requested quantity");
}
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
} else if (value(allocation.getPlannedPackageCount()) > 0
&& value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) {
throw conflict("Requested carton quantity exceeds the recommended pallet package count");
}
BigDecimal perPackage = perPackageVolume(line);
carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId());
carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId());
carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId());
carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now());
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
LocalDateTime now = LocalDateTime.now();
for (InboundPlanCarton item : cartonsToScan) {
item.setOrderId(line.getOrderId()); item.setPlanId(plan.getId()); item.setLineId(line.getId());
item.setPlanPalletId(pallet.getId()); item.setWarehousePalletId(pallet.getWarehousePalletId());
item.setWarehouseLocationId(pallet.getWarehouseLocationId()); item.setPdaTaskId(task.getId());
item.setStatus("ON_PALLET"); item.setBoundAt(now); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); repository.update(item);
}
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + requestedCount);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + requestedCount);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + requestedCount);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(),
allocation.getId(), pallet.getActualPackageCount());
allocation.getId(), pallet.getActualPackageCount(), pallet.getPalletNo(), requestedCount);
saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result);
return result;
}
@@ -378,6 +404,7 @@ public class InboundPdaWorkflowService {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed");
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
@@ -414,7 +441,36 @@ public class InboundPdaWorkflowService {
plan.setUpdatedAt(now);
repository.update(plan);
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
return task(task, planNo);
return taskResponse(task, plan, planNo);
}
/** Explicit operator decision to receive only the cartons actually picked. */
@Transactional
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PARTIAL_COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
List<InboundPlanLine> lines = repository.lines(plan.getId());
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
if (picked <= 0) throw conflict("At least one carton must be picked before partial inbound completion");
if (picked >= expected) throw conflict("All cartons are picked; use normal inbound completion");
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now, true);
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
if ("ON_PALLET".equals(carton.getStatus())) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
}
task.setStatus("PARTIAL_INBOUND"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "PARTIAL_COMPLETE", request.idempotencyKey(), request, result);
return result;
}
@Transactional
@@ -433,7 +489,7 @@ public class InboundPdaWorkflowService {
task.setUpdatedAt(uploadedAt);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
PdaResponses.Task result = task(task, planNo);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result);
return result;
}
@@ -597,7 +653,7 @@ public class InboundPdaWorkflowService {
private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed");
if (terminal(task.getStatus())) throw conflict("PDA task is already completed");
return task;
}
@@ -613,6 +669,24 @@ public class InboundPdaWorkflowService {
return value;
}
private InboundPlanPallet autoPallet(InboundPlan plan, InboundPlanCarton carton) {
List<InboundPlanPallet> candidates = repository.pallets(plan.getId()).stream()
.filter(pallet -> sameWarehouse(carton, pallet))
.filter(pallet -> !isNotAssigned(pallet))
.sorted(Comparator.comparingInt(pallet -> value(pallet.getSequenceNo())))
.toList();
if (candidates.isEmpty()) throw conflict("No assigned pallet exists for the carton destination warehouse");
Map<Long, Integer> plannedByPallet = new HashMap<>();
for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) {
plannedByPallet.merge(allocation.getPalletId(), value(allocation.getPlannedPackageCount()), Integer::sum);
}
InboundPlanPallet selected = candidates.stream()
.filter(pallet -> value(pallet.getActualPackageCount()) < plannedByPallet.getOrDefault(pallet.getId(), Integer.MAX_VALUE))
.findFirst().orElse(null);
if (selected == null) throw conflict("All assigned pallets for the carton destination warehouse are full");
return repository.lockPallet(plan.getId(), selected.getId());
}
private InboundPlanLine requireLine(Long planId, Long id) {
InboundPlanLine value = repository.line(planId, id);
if (value == null) throw notFound("Inbound plan line not found");
@@ -629,9 +703,19 @@ public class InboundPdaWorkflowService {
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
return task(task, planNo, 0, 0);
}
private PdaResponses.Task taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) {
int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
int picked = repository.allocations(plan.getId()).stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
return task(task, planNo, expected, picked);
}
private PdaResponses.Task task(InboundPdaTask task, String planNo, int expected, int picked) {
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt());
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked);
}
private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
@@ -725,6 +809,7 @@ public class InboundPdaWorkflowService {
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -46,6 +46,13 @@ public class InboundStockReceiptService {
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
}
@Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt, boolean allowPartial) {
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> customerId.equals(order.getCustomerId()))
@@ -64,8 +71,8 @@ public class InboundStockReceiptService {
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (receivedAllocationIds.equals(expectedAllocationIds)) {
activateDropship(customerId, task.getId(), orderById, existing);
completeOrders(customerId, plan, orderById, receivedAt);
if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing);
if (!allowPartial) completeOrders(customerId, plan, orderById, receivedAt);
return;
}
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
@@ -79,11 +86,13 @@ public class InboundStockReceiptService {
.getOrDefault(line.getId(), List.of());
long pickedPackages = lineAllocations.stream()
.mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum();
if (pickedPackages != value(line.getRequiredPackageCount())) {
if (pickedPackages == 0 && allowPartial) continue;
if ((!allowPartial && pickedPackages != value(line.getRequiredPackageCount()))
|| (allowPartial && pickedPackages > value(line.getRequiredPackageCount()))) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), pickedPackages);
? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount()));
for (InboundPlanPalletAllocation allocation : lineAllocations) {
InboundPlanPallet pallet = requirePallet(palletById, allocation);
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
@@ -96,12 +105,14 @@ public class InboundStockReceiptService {
}
}
}
if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
if (!allowPartial && skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
throw conflict("Inbound SKU package snapshots were not fully received");
}
}
activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt);
if (!allowPartial) {
activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt);
}
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
@@ -57,6 +57,11 @@ public class InboundPlanService {
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
return convert(customerId, selectedOrderId, List.of());
}
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
@@ -88,13 +93,14 @@ public class InboundPlanService {
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount()))
order.packageCount(), order.recommendedPalletCount()))
.toList());
plan.setPalletCount(allocation.pallets().size());
planMapper.insert(plan);
lineByOrder.values().forEach(lineMapper::insert);
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation, orderById);
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation, orderById,
sourcePalletNos == null ? List.of() : sourcePalletNos);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
@@ -175,7 +181,8 @@ public class InboundPlanService {
}
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById) {
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById,
List<String> sourcePalletNos) {
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
@@ -185,7 +192,10 @@ public class InboundPlanService {
pallet.setId(IdWorker.getId());
pallet.setCustomerId(plan.getCustomerId());
pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1));
int palletIndex = result.size();
String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null;
pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank()
? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim());
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
@@ -34,6 +34,23 @@ public final class PalletAllocator {
PalletDraft current = null;
int sequence = 0;
for (LineInput line : group) {
if (line.recommendedPalletCount() > 0) {
int minimumByVolume = line.volumeCbm().divide(PALLET_CAPACITY_CBM, 0, RoundingMode.CEILING).intValue();
int count = Math.max(line.recommendedPalletCount(), minimumByVolume);
BigDecimal remaining = line.volumeCbm();
for (int index = 0; index < count; index++) {
PalletDraft recommended = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO);
pallets.add(recommended);
int remainingPallets = count - index;
BigDecimal allocated = remainingPallets == 1 ? remaining
: remaining.divide(BigDecimal.valueOf(remainingPallets), 4, RoundingMode.HALF_UP);
recommended.add(allocated);
allocations.add(new AllocationDraft(line.orderId(), recommended, allocated, 0));
remaining = remaining.subtract(allocated);
}
current = null;
continue;
}
BigDecimal remaining = line.volumeCbm();
while (remaining.signum() > 0) {
if (current == null || current.remainingCapacity().signum() == 0) {
@@ -85,7 +102,11 @@ public final class PalletAllocator {
return List.copyOf(result);
}
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount,
int recommendedPalletCount) {
public LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
this(orderId, warehouseKey, volumeCbm, packageCount, 0);
}
}
public static final class PalletDraft {
@@ -156,7 +156,9 @@ public class LoadingCompletedConsumer {
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setPackageCount(source.packageCount());
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
@@ -192,7 +194,11 @@ public class LoadingCompletedConsumer {
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
if ("FIRST_LEG".equals(orderType)) {
InboundPlanResponse plan = inboundPlans.convert(customerId, order.getId());
List<String> sourcePalletNos = source.pallets() == null ? List.of() : source.pallets().stream()
.map(FirstLegLoadingPlanCompletedV1.PalletSnapshot::palletNo).filter(Objects::nonNull).toList();
InboundPlanResponse plan = sourcePalletNos.isEmpty()
? inboundPlans.convert(customerId, order.getId())
: inboundPlans.convert(customerId, order.getId(), sourcePalletNos);
registerInTransit(event, payload, source, order, plan.planNo());
}
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
@@ -161,6 +161,30 @@ class InboundPdaWorkflowServiceTest {
assertEquals(3, targetPallet.getActualPackageCount());
}
@Test
void cartonScanCanAutoSelectDestinationPalletAndBindAQuantity() {
InboundPlanCarton first = carton("BOX-BATCH-1", "UNASSIGNED", targetLine.getId(), "WH-A");
InboundPlanCarton second = carton("BOX-BATCH-2", "UNASSIGNED", targetLine.getId(), "WH-A");
second.setId(42L);
activeTask();
when(repository.lockCarton("100", "BOX-BATCH-1")).thenReturn(first);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.cartons(plan.getId())).thenReturn(List.of(first, second));
when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target);
var result = service.scanCarton("100", "LIP1",
new PdaRequests.ScanCarton("BOX-BATCH-1", null, 2, "scan-batch", "7", "worker"));
assertEquals(targetPallet.getId(), result.planPalletId());
assertEquals("PLT-21", result.palletNo());
assertEquals(2, result.scannedPackageCount());
assertEquals("ON_PALLET", first.getStatus());
assertEquals("ON_PALLET", second.getStatus());
assertEquals(2, target.getPickedPackageCount());
}
@Test
void cartonScanRejectsDuplicateAndWrongDestination() {
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
@@ -430,6 +454,26 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).allocations(plan.getId());
}
@Test
void partialCompleteStocksOnlyActualPickedPackagesAndKeepsPartialStatus() {
target.setPickedPackageCount(2);
activeTask();
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
when(repository.cartons(plan.getId())).thenReturn(List.of());
var result = service.partialComplete("100", "LIP1",
new PdaRequests.ForceComplete("partial-1", "7", "worker"));
assertEquals("PARTIAL_INBOUND", result.status());
assertEquals(5, result.expectedPackageCount());
assertEquals(2, result.pickedPackageCount());
assertEquals("PARTIAL_INBOUND", plan.getStatus());
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
eq(List.of(target)), eq(List.of(targetPallet)), any(), eq(true));
}
@Test
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
InboundPdaOperation existing = new InboundPdaOperation();
@@ -49,6 +49,16 @@ class PalletAllocatorTest {
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
}
@Test
void keepsAtLeastTheFirstLegRecommendedPalletCount() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("1.5000"), 6, 3)));
assertEquals(3, result.pallets().size());
assertEquals(6, result.allocations().stream()
.mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum());
}
private PalletAllocator.Result allocate(String volume) {
return PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),