允许私人地址按独立托盘生成海外入库计划
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
|
||||
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
|
||||
| 统一认证与撤销 | 复用 common-security 的Redis会话及Auth/IAM权威指纹校验,故障拒绝访问;Lastleg不保存独立授权投影 |
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;私人地址可无目的仓 ID/编码,票内地址名称须非空且一致,托盘分配按来源订单独立归属,接柜仓仍须有效绑定;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
|
||||
|
||||
+2
-2
@@ -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} " +
|
||||
|
||||
+9
-2
@@ -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 } }
|
||||
|
||||
+10
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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(
|
||||
|
||||
+38
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user