fix container customer snapshot schema migration
This commit is contained in:
+2
-2
@@ -3,7 +3,7 @@ package com.baiyue.common.event;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
|
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
|
||||||
public record FirstLegLoadingPlanCompletedV2(
|
public record FirstLegLoadingPlanCompletedV2(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
@@ -71,7 +71,7 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一键代发入仓单的托盘冻结快照。 */
|
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
|
||||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||||
|
|
||||||
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
|
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
|
||||||
|
|||||||
@@ -4938,6 +4938,21 @@ BEGIN
|
|||||||
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
|
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
|
||||||
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
|
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
|
||||||
END IF;
|
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()
|
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
|
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);
|
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
|
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;
|
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id;
|
||||||
END IF;
|
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
|
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
|
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
|
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
|
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;
|
ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id;
|
||||||
END IF;
|
END IF;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public interface LoadingCompletedEventMapper {
|
|||||||
""")
|
""")
|
||||||
List<Row> rows(@Param("planId") long planId);
|
List<Row> rows(@Param("planId") long planId);
|
||||||
|
|
||||||
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
|
/** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT i.account_id, i.inbound_id AS source_inbound_id,
|
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,
|
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
|
||||||
|
|||||||
+2
-3
@@ -89,8 +89,7 @@ public class LoadingCompletedOutboxService {
|
|||||||
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
|
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
|
||||||
.toList();
|
.toList();
|
||||||
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
|
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
|
||||||
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(first.getBusinessType())
|
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = palletSnapshots(sorted, palletRows);
|
||||||
? palletSnapshots(sorted, palletRows) : List.of();
|
|
||||||
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
|
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
|
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
|
||||||
}
|
}
|
||||||
@@ -99,7 +98,7 @@ public class LoadingCompletedOutboxService {
|
|||||||
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
|
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
|
||||||
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
||||||
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
|
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);
|
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
|
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
|
||||||
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
|
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
|
||||||
END IF;
|
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()
|
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
|
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
|
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
|
||||||
|
|||||||
+13
@@ -76,6 +76,19 @@ class SmartLoadingSqlContractTest {
|
|||||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
.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
|
@Test
|
||||||
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
|
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
|
||||||
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
|
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
|
||||||
|
|||||||
+15
@@ -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/sourceInboundDetailId").asText()).isEqualTo("101");
|
||||||
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
|
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/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(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
||||||
assertThat(first.eventVersion()).isEqualTo(2);
|
assertThat(first.eventVersion()).isEqualTo(2);
|
||||||
}
|
}
|
||||||
@@ -118,6 +119,20 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
|
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) {
|
private Row row(long account,long inbound,long detail) {
|
||||||
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
|
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);
|
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
||||||
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
|
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
|
||||||
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
|
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
|
||||||
- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。
|
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
|
||||||
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
|
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
|
||||||
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
|
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
|
||||||
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
|
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
|
||||||
|
|||||||
+14
@@ -17,6 +17,7 @@ public record InboundPlanListResponse(
|
|||||||
String fbaWarehouseCode,
|
String fbaWarehouseCode,
|
||||||
String customerName,
|
String customerName,
|
||||||
Integer packageCount,
|
Integer packageCount,
|
||||||
|
Integer pickedPackageCount,
|
||||||
BigDecimal volumeCbm,
|
BigDecimal volumeCbm,
|
||||||
Integer palletCount,
|
Integer palletCount,
|
||||||
String status,
|
String status,
|
||||||
@@ -27,4 +28,17 @@ public record InboundPlanListResponse(
|
|||||||
String dispatchStatus,
|
String dispatchStatus,
|
||||||
LocalDateTime createdAt
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -14,10 +14,20 @@ public record InboundPlanResponse(
|
|||||||
int lineCount,
|
int lineCount,
|
||||||
int palletCount,
|
int palletCount,
|
||||||
BigDecimal totalVolumeCbm,
|
BigDecimal totalVolumeCbm,
|
||||||
|
int expectedPackageCount,
|
||||||
|
int pickedPackageCount,
|
||||||
List<Line> lines,
|
List<Line> lines,
|
||||||
List<Pallet> pallets,
|
List<Pallet> pallets,
|
||||||
List<Allocation> allocations
|
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,
|
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
|
||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
|
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
|
||||||
|
|||||||
+2
-1
@@ -18,6 +18,7 @@ public interface InboundPlanOrderMapper {
|
|||||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
@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," +
|
"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.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," +
|
"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_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
|
||||||
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
|
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
|
||||||
@@ -30,7 +31,7 @@ public interface InboundPlanOrderMapper {
|
|||||||
"AS destinationMismatchCount " +
|
"AS destinationMismatchCount " +
|
||||||
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
|
"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} " +
|
"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 " +
|
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
|
||||||
"ORDER BY o.id FOR UPDATE")
|
"ORDER BY o.id FOR UPDATE")
|
||||||
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
|
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
|
||||||
|
|||||||
+3
-1
@@ -39,7 +39,9 @@ public interface InboundPlanQueryMapper {
|
|||||||
"l.order_id orderId,l.inbound_code inboundCode,o.order_type orderType," +
|
"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_id warehouseId,l.destination_warehouse_code warehouseCode," +
|
||||||
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
|
"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," +
|
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
|
||||||
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
|
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
|
||||||
"a.expected_loading_at expectedLoadingAt," +
|
"a.expected_loading_at expectedLoadingAt," +
|
||||||
|
|||||||
+12
-1
@@ -11,6 +11,7 @@ public record SourceOrderSnapshot(
|
|||||||
String inboundOrderNo,
|
String inboundOrderNo,
|
||||||
BigDecimal volumeCbm,
|
BigDecimal volumeCbm,
|
||||||
int packageCount,
|
int packageCount,
|
||||||
|
int recommendedPalletCount,
|
||||||
String fbaWarehouseCode,
|
String fbaWarehouseCode,
|
||||||
String destinationWarehouseId,
|
String destinationWarehouseId,
|
||||||
String destinationWarehouseCode,
|
String destinationWarehouseCode,
|
||||||
@@ -18,13 +19,23 @@ public record SourceOrderSnapshot(
|
|||||||
int destinationCount,
|
int destinationCount,
|
||||||
int destinationMismatchCount
|
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,
|
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
|
||||||
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
|
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
|
||||||
int packageCount, String destinationWarehouseId,
|
int packageCount, String destinationWarehouseId,
|
||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int destinationCount, int destinationMismatchCount) {
|
int destinationCount, int destinationMismatchCount) {
|
||||||
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
|
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
|
||||||
packageCount, null, destinationWarehouseId, destinationWarehouseCode,
|
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, destinationCount, destinationMismatchCount);
|
destinationWarehouseName, destinationCount, destinationMismatchCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -108,6 +108,15 @@ public class InboundPdaController {
|
|||||||
return ApiResponse.success(service.complete(scope(user, customerId), planNo));
|
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")
|
@PostMapping("/unload-photos")
|
||||||
public ApiResponse<PdaResponses.Task> uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<PdaResponses.Task> uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String planNo,
|
@PathVariable String planNo,
|
||||||
|
|||||||
+10
-2
@@ -20,8 +20,16 @@ public final class PdaRequests {
|
|||||||
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
|
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
|
||||||
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
|
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
|
||||||
|
|
||||||
public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId,
|
public record ScanCarton(@NotBlank String cartonMark, Long planPalletId, Integer packageCount,
|
||||||
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
|
@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,
|
public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
|
||||||
String operatorId, String operatorName) {}
|
String operatorId, String operatorName) {}
|
||||||
|
|||||||
+16
-2
@@ -10,7 +10,16 @@ public final class PdaResponses {
|
|||||||
public record Task(long taskId, String taskNo, String planNo, String status,
|
public record Task(long taskId, String taskNo, String planNo, String status,
|
||||||
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
|
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
|
||||||
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
|
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) {}
|
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||||
|
|
||||||
@@ -29,5 +38,10 @@ public final class PdaResponses {
|
|||||||
boolean sourcePalletScrapped) {}
|
boolean sourcePalletScrapped) {}
|
||||||
|
|
||||||
public record Carton(String result, String cartonMark, long planPalletId, long allocationId,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-19
@@ -73,6 +73,9 @@ public class InboundPdaWorkflowService {
|
|||||||
}
|
}
|
||||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (task != null && terminal(task.getStatus())) {
|
||||||
|
throw conflict("PDA inbound task is already completed");
|
||||||
|
}
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
task = new InboundPdaTask();
|
task = new InboundPdaTask();
|
||||||
task.setId(IdWorker.getId());
|
task.setId(IdWorker.getId());
|
||||||
@@ -91,7 +94,7 @@ public class InboundPdaWorkflowService {
|
|||||||
plan.setStatus("PDA_PENDING");
|
plan.setStatus("PDA_PENDING");
|
||||||
plan.setUpdatedAt(now);
|
plan.setUpdatedAt(now);
|
||||||
repository.update(plan);
|
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);
|
saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -132,7 +135,7 @@ public class InboundPdaWorkflowService {
|
|||||||
InboundPlan plan = requirePlan(customerId, planNo);
|
InboundPlan plan = requirePlan(customerId, planNo);
|
||||||
InboundPdaTask task = repository.task(customerId, plan.getId());
|
InboundPdaTask task = repository.task(customerId, plan.getId());
|
||||||
if (task == null) throw notFound("PDA task not found");
|
if (task == null) throw notFound("PDA task not found");
|
||||||
return task(task, planNo);
|
return taskResponse(task, plan, planNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -220,39 +223,62 @@ public class InboundPdaWorkflowService {
|
|||||||
if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
|
if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
|
||||||
throw conflict("Carton mark has already been scanned; remove it before scanning again");
|
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 (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");
|
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
|
||||||
InboundPlanLine line = carton.getLineId() == null
|
InboundPlanLine line = carton.getLineId() == null
|
||||||
? repository.lineForCarton(plan.getId(), carton)
|
? repository.lineForCarton(plan.getId(), carton)
|
||||||
: requireLine(plan.getId(), carton.getLineId());
|
: requireLine(plan.getId(), carton.getLineId());
|
||||||
if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan");
|
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");
|
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());
|
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
|
||||||
if (allocation == null) {
|
if (allocation == null) {
|
||||||
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
|
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
|
||||||
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
||||||
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
|
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
|
||||||
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
|
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);
|
BigDecimal perPackage = perPackageVolume(line);
|
||||||
carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId());
|
LocalDateTime now = LocalDateTime.now();
|
||||||
carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId());
|
for (InboundPlanCarton item : cartonsToScan) {
|
||||||
carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId());
|
item.setOrderId(line.getOrderId()); item.setPlanId(plan.getId()); item.setLineId(line.getId());
|
||||||
carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now());
|
item.setPlanPalletId(pallet.getId()); item.setWarehousePalletId(pallet.getWarehousePalletId());
|
||||||
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
item.setWarehouseLocationId(pallet.getWarehouseLocationId()); item.setPdaTaskId(task.getId());
|
||||||
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1);
|
item.setStatus("ON_PALLET"); item.setBoundAt(now); item.setUpdatedAt(now);
|
||||||
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1);
|
item.setVersion(value(item.getVersion()) + 1); repository.update(item);
|
||||||
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
|
}
|
||||||
|
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");
|
allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
|
||||||
bump(allocation); repository.update(allocation);
|
bump(allocation); repository.update(allocation);
|
||||||
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1);
|
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + requestedCount);
|
||||||
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
|
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());
|
repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName());
|
||||||
PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(),
|
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);
|
saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -378,6 +404,7 @@ public class InboundPdaWorkflowService {
|
|||||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||||
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
|
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
|
||||||
if ("COMPLETED".equals(task.getStatus())) return task(task, planNo);
|
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);
|
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
|
||||||
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|
||||||
|| task.getEstimatedEmptyReturnAt() == null) {
|
|| task.getEstimatedEmptyReturnAt() == null) {
|
||||||
@@ -414,7 +441,36 @@ public class InboundPdaWorkflowService {
|
|||||||
plan.setUpdatedAt(now);
|
plan.setUpdatedAt(now);
|
||||||
repository.update(plan);
|
repository.update(plan);
|
||||||
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
|
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
|
@Transactional
|
||||||
@@ -433,7 +489,7 @@ public class InboundPdaWorkflowService {
|
|||||||
task.setUpdatedAt(uploadedAt);
|
task.setUpdatedAt(uploadedAt);
|
||||||
task.setVersion(value(task.getVersion()) + 1);
|
task.setVersion(value(task.getVersion()) + 1);
|
||||||
repository.update(task);
|
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);
|
saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -597,7 +653,7 @@ public class InboundPdaWorkflowService {
|
|||||||
private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) {
|
private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) {
|
||||||
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||||
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
|
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;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +669,24 @@ public class InboundPdaWorkflowService {
|
|||||||
return value;
|
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) {
|
private InboundPlanLine requireLine(Long planId, Long id) {
|
||||||
InboundPlanLine value = repository.line(planId, id);
|
InboundPlanLine value = repository.line(planId, id);
|
||||||
if (value == null) throw notFound("Inbound plan line not found");
|
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) {
|
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(),
|
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
|
||||||
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
|
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
|
||||||
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt());
|
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
|
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 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 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 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); }
|
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||||
|
|||||||
+16
-5
@@ -46,6 +46,13 @@ public class InboundStockReceiptService {
|
|||||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||||
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
|
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<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
|
||||||
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
|
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
|
||||||
.filter(order -> customerId.equals(order.getCustomerId()))
|
.filter(order -> customerId.equals(order.getCustomerId()))
|
||||||
@@ -64,8 +71,8 @@ public class InboundStockReceiptService {
|
|||||||
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
|
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
|
||||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
if (receivedAllocationIds.equals(expectedAllocationIds)) {
|
if (receivedAllocationIds.equals(expectedAllocationIds)) {
|
||||||
activateDropship(customerId, task.getId(), orderById, existing);
|
if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing);
|
||||||
completeOrders(customerId, plan, orderById, receivedAt);
|
if (!allowPartial) completeOrders(customerId, plan, orderById, receivedAt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
|
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());
|
.getOrDefault(line.getId(), List.of());
|
||||||
long pickedPackages = lineAllocations.stream()
|
long pickedPackages = lineAllocations.stream()
|
||||||
.mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum();
|
.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");
|
throw conflict("PDA picked packages must equal the inbound order package total");
|
||||||
}
|
}
|
||||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
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) {
|
for (InboundPlanPalletAllocation allocation : lineAllocations) {
|
||||||
InboundPlanPallet pallet = requirePallet(palletById, allocation);
|
InboundPlanPallet pallet = requirePallet(palletById, allocation);
|
||||||
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||||
@@ -96,13 +105,15 @@ 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");
|
throw conflict("Inbound SKU package snapshots were not fully received");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!allowPartial) {
|
||||||
activateDropship(customerId, task.getId(), orderById, created);
|
activateDropship(customerId, task.getId(), orderById, created);
|
||||||
completeOrders(customerId, plan, orderById, receivedAt);
|
completeOrders(customerId, plan, orderById, receivedAt);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
|
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
|
||||||
LocalDateTime completedAt) {
|
LocalDateTime completedAt) {
|
||||||
|
|||||||
+14
-4
@@ -57,6 +57,11 @@ public class InboundPlanService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
|
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);
|
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
|
||||||
if (orders == null || orders.isEmpty()) {
|
if (orders == null || orders.isEmpty()) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
|
||||||
@@ -88,13 +93,14 @@ public class InboundPlanService {
|
|||||||
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
|
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
|
||||||
PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream()
|
PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream()
|
||||||
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
|
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
|
||||||
order.packageCount()))
|
order.packageCount(), order.recommendedPalletCount()))
|
||||||
.toList());
|
.toList());
|
||||||
plan.setPalletCount(allocation.pallets().size());
|
plan.setPalletCount(allocation.pallets().size());
|
||||||
planMapper.insert(plan);
|
planMapper.insert(plan);
|
||||||
|
|
||||||
lineByOrder.values().forEach(lineMapper::insert);
|
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);
|
createAllocations(plan, allocation, lineByOrder, persistedPallets);
|
||||||
|
|
||||||
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
|
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
|
||||||
@@ -175,7 +181,8 @@ public class InboundPlanService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
|
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()
|
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
|
||||||
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
|
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
|
||||||
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
|
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
|
||||||
@@ -185,7 +192,10 @@ public class InboundPlanService {
|
|||||||
pallet.setId(IdWorker.getId());
|
pallet.setId(IdWorker.getId());
|
||||||
pallet.setCustomerId(plan.getCustomerId());
|
pallet.setCustomerId(plan.getCustomerId());
|
||||||
pallet.setPlanId(plan.getId());
|
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.setDestinationWarehouseId(sample.destinationWarehouseId());
|
||||||
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
|
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
|
||||||
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
|
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
|
||||||
|
|||||||
+22
-1
@@ -34,6 +34,23 @@ public final class PalletAllocator {
|
|||||||
PalletDraft current = null;
|
PalletDraft current = null;
|
||||||
int sequence = 0;
|
int sequence = 0;
|
||||||
for (LineInput line : group) {
|
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();
|
BigDecimal remaining = line.volumeCbm();
|
||||||
while (remaining.signum() > 0) {
|
while (remaining.signum() > 0) {
|
||||||
if (current == null || current.remainingCapacity().signum() == 0) {
|
if (current == null || current.remainingCapacity().signum() == 0) {
|
||||||
@@ -85,7 +102,11 @@ public final class PalletAllocator {
|
|||||||
return List.copyOf(result);
|
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 {
|
public static final class PalletDraft {
|
||||||
|
|||||||
+8
-2
@@ -156,7 +156,9 @@ public class LoadingCompletedConsumer {
|
|||||||
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
|
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
|
||||||
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
|
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
|
||||||
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
|
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.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
|
||||||
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
||||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
|
.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.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
||||||
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
|
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
|
||||||
if ("FIRST_LEG".equals(orderType)) {
|
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());
|
registerInTransit(event, payload, source, order, plan.planNo());
|
||||||
}
|
}
|
||||||
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
||||||
|
|||||||
+44
@@ -161,6 +161,30 @@ class InboundPdaWorkflowServiceTest {
|
|||||||
assertEquals(3, targetPallet.getActualPackageCount());
|
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
|
@Test
|
||||||
void cartonScanRejectsDuplicateAndWrongDestination() {
|
void cartonScanRejectsDuplicateAndWrongDestination() {
|
||||||
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
|
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
|
||||||
@@ -430,6 +454,26 @@ class InboundPdaWorkflowServiceTest {
|
|||||||
verify(repository, never()).allocations(plan.getId());
|
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
|
@Test
|
||||||
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
|
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
|
||||||
InboundPdaOperation existing = new InboundPdaOperation();
|
InboundPdaOperation existing = new InboundPdaOperation();
|
||||||
|
|||||||
+10
@@ -49,6 +49,16 @@ class PalletAllocatorTest {
|
|||||||
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
|
.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) {
|
private PalletAllocator.Result allocate(String volume) {
|
||||||
return PalletAllocator.allocate(List.of(
|
return PalletAllocator.allocate(List.of(
|
||||||
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),
|
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),
|
||||||
|
|||||||
Reference in New Issue
Block a user