允许私人地址按独立托盘生成海外入库计划

This commit is contained in:
2026-11-04 13:35:23 +08:00
parent 0d8e64c42c
commit a01598bd21
7 changed files with 88 additions and 6 deletions
@@ -44,7 +44,7 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),NULLIF(TRIM(d.destination_warehouse_name),''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
@@ -93,7 +93,7 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),NULLIF(TRIM(d.destination_warehouse_name),''),'#EMPTY#')) END AS destinationCount," +
"0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " +
"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} " +
@@ -291,7 +291,8 @@ public class InboundPlanService {
boolean fba = isFbaDestination(row);
if (fba ? trimToNull(row.fbaWarehouseCode()) == null
: row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null
&& trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY");
&& trimToNull(row.destinationWarehouseCode()) == null
&& trimToNull(row.destinationWarehouseName()) == null) blockers.add("DESTINATION_NOT_READY");
if (row.packageCount() < 0 || row.volumeCbm() == null || row.volumeCbm().signum() < 0
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID");
}
@@ -1181,7 +1182,13 @@ public class InboundPlanService {
private String warehouseKey(SourceOrderSnapshot order) {
String id = trimToNull(order.destinationWarehouseId());
String code = trimToNull(order.destinationWarehouseCode());
return id != null ? "ID:" + id : code == null ? null : "CODE:" + code;
if (id != null) return "ID:" + id;
if (code != null) return "CODE:" + code;
if (trimToNull(order.fbaWarehouseCode()) == null
&& trimToNull(order.destinationWarehouseName()) != null) {
return "PRIVATE:" + order.orderId();
}
return null;
}
private static String trimToNull(String value) {
@@ -773,7 +773,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/container-preview:
get:
summary: 预览按柜生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。
description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;私人地址允许目的仓 ID 和编码为空,仍须具备票内一致的非空地址名称和有效接柜仓绑定。托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -48,6 +48,15 @@ class InboundPlanOrderMapperContractTest {
row = jdbc.queryForMap(sql, "C1", 1L);
assertEquals("ABE3", row.get("fbaWarehouseCode"));
assertEquals(row.get("destinationWarehouseCode"), row.get("fbaWarehouseCode"));
jdbc.update("INSERT INTO ll_order (id,customer_id) VALUES (2,'C1')");
jdbc.update("INSERT INTO ll_order_detail VALUES (2,NULL,NULL,'私人地址 A')");
row = jdbc.queryForMap(sql, "C1", 2L);
assertEquals(1L, ((Number) row.get("destinationCount")).longValue());
assertEquals("私人地址 A", row.get("destinationWarehouseName"));
jdbc.update("INSERT INTO ll_order_detail VALUES (2,NULL,NULL,'私人地址 B')");
row = jdbc.queryForMap(sql, "C1", 2L);
assertEquals(2L, ((Number) row.get("destinationCount")).longValue());
}
@Test
@@ -57,6 +66,7 @@ class InboundPlanOrderMapperContractTest {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode"),
method.getName());
assertTrue(sql.contains("NULLIF(TRIM(d.destination_warehouse_name),'')"), method.getName());
}
}
@@ -70,6 +70,33 @@ class ContainerInboundPlanPreviewTest {
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
}
@Test
void privateAddressReadinessRequiresOneNamedDestinationWithoutMasterWarehouseIdentity() {
when(orderMapper.containerPreviewOrders("BOOKING-PRIVATE")).thenReturn(List.of(
privateAddressRow(1L, "私人地址 A", 1),
privateAddressRow(2L, " ", 1),
privateAddressRow(3L, "私人地址 B", 2)));
var result = service.previewContainer("BOOKING-PRIVATE");
var orders = result.groups().stream().flatMap(group -> group.orders().stream()).toList();
var ready = orders.stream().filter(order -> order.orderId().equals("1")).findFirst().orElseThrow();
assertEquals("READY", ready.readiness());
assertEquals("PRIVATE_ADDRESS", ready.destinationType());
assertEquals(List.of(), ready.blockers());
assertEquals(1, ready.targetPalletCount());
assertEquals(2, result.blockedOrderCount());
orders.stream().filter(order -> !order.orderId().equals("1")).forEach(order ->
assertEquals(List.of("DESTINATION_NOT_READY"), order.blockers()));
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow privateAddressRow(long id, String name, int destinations) {
return new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", id, "LM-" + id, "FO-" + id,
"测试客户", "FIRST_LEG", "OVERSEAS", "IN-" + id, "PENDING_ASSIGNMENT", null,
"MSCU6639870", 1, 4, BigDecimal.ONE, "193000000000200003", "US-LAX-01",
"洛杉矶仓", null, null, null, name, destinations, false, 1,
null, null, null, null, 0);
}
@Test
void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() {
when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of(
@@ -37,6 +37,7 @@ import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -101,6 +102,43 @@ class InboundPlanServiceTest {
.thenReturn(java.time.LocalDate.of(2026, 9, 15));
}
@Test
void convertsPrivateAddressWithoutCreatingADestinationWarehouseIdentity() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-PRIVATE",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 4, 1,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null, null, null, "私人地址 A", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
assertEquals(1, result.palletCount());
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper).insert(line.capture());
assertNull(line.getValue().getDestinationWarehouseId());
assertNull(line.getValue().getDestinationWarehouseCode());
assertEquals("私人地址 A", line.getValue().getDestinationWarehouseName());
assertEquals(4, line.getValue().getRequiredPackageCount());
ArgumentCaptor<InboundPlanPallet> pallet = ArgumentCaptor.forClass(InboundPlanPallet.class);
verify(palletMapper).insert(pallet.capture());
assertEquals("私人地址 A", pallet.getValue().getDestinationWarehouseName());
}
@Test
void rejectsPrivateAddressWithoutANamedDestination() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-PRIVATE",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 4, 1,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null, null, null, " ", 1, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
ResponseStatusException failure = assertThrows(ResponseStatusException.class,
() -> service.convert("100", 2L));
assertEquals(400, failure.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void convertsOnlyTheSelectedInboundOrderToItsOwnPlan() throws Exception {
assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class)