fix(lastleg): enforce container warehouse plan facts

This commit is contained in:
2026-09-08 11:00:39 +08:00
parent 61ffff1be0
commit cc6df5993f
8 changed files with 161 additions and 35 deletions
@@ -11,11 +11,7 @@ public record ManualInboundPlanRequest(
@NotBlank @Size(max = 128) String customerId,
@NotNull Long orderId,
@NotNull @Min(0) Integer expectedOrderVersion,
@NotNull Long plannedWarehouseId,
@NotBlank @Size(max = 128) String plannedWarehouseCode,
@NotBlank @Size(max = 255) String plannedWarehouseName,
@NotNull LocalDate plannedArrivalDate,
@NotBlank @Size(max = 128) String inboundCode,
@NotNull @Min(1) @Max(999) Integer palletCount,
@NotBlank @Size(max = 32) String palletType,
@NotNull Boolean assigned,
@@ -16,6 +16,17 @@ public interface InboundPlanOrderMapper {
String orderType) { }
record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName) { }
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
String overseasWarehouseId) { }
@Select("SELECT c.customer_id AS customerId,c.id AS orderId," +
"c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " +
"FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " +
"WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " +
"AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " +
"ORDER BY c.customer_id,c.id FOR UPDATE")
List<ContainerWarehouseRef> lockContainerWarehouseFacts(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," +
"order_type AS orderType " +
@@ -75,6 +75,13 @@ public class InboundPlanService {
if (key == null || key.length() > 128) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符");
}
List<InboundPlanOrderMapper.ContainerWarehouseRef> containerFacts =
orderMapper.lockContainerWarehouseFacts(customerId, request.orderId());
if (containerFacts == null || containerFacts.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜");
}
String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream()
.map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList());
List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId());
if (snapshots == null || snapshots.size() != 1) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在");
@@ -96,20 +103,14 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试");
}
if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划");
if (!Objects.equals(trimToNull(order.inboundCode()), trimToNull(request.inboundCode()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "入仓码与尾程订单冻结事实不一致");
}
WarehouseResourceGateway.WarehouseReference warehouse =
warehouseGateway.requireActiveSharedWarehouse(request.plannedWarehouseId());
if (!Objects.equals(String.valueOf(warehouse.warehouseId()), trimToNull(order.overseasWarehouseId()))
|| !Objects.equals(warehouse.warehouseCode(), request.plannedWarehouseCode().trim())
|| !Objects.equals(warehouse.warehouseName(), request.plannedWarehouseName().trim())
|| !Objects.equals(warehouse.warehouseCode(), trimToNull(order.overseasWarehouseCode()))
|| !Objects.equals(warehouse.warehouseName(), trimToNull(order.overseasWarehouseName()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "计划入仓仓库不存在、已停用或与订单预分配事实不一致");
if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试");
}
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
requiredReceivingWarehouse(containerWarehouseId);
validateOrders(snapshots);
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request);
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
request, receivingWarehouse);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key);
@@ -180,12 +181,13 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual) {
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
@@ -262,7 +264,7 @@ public class InboundPlanService {
orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(),
"Inbound order converted to inbound plan " + plan.getPlanNo(), now);
}
return detail(customerId, plan);
return detail(customerId, plan, receivingWarehouse);
}
/** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */
@@ -326,12 +328,14 @@ public class InboundPlanService {
}
wholePallets.add(sourcePallets);
}
requireSingleWarehouseId(snapshots.stream().flatMap(List::stream)
.map(SourceOrderSnapshot::overseasWarehouseId).toList());
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(),
wholePallets.get(index), null));
wholePallets.get(index), null, null));
}
return List.copyOf(created);
}
@@ -623,16 +627,21 @@ public class InboundPlanService {
}
private InboundPlanResponse detail(String customerId, InboundPlan plan) {
return detail(customerId, plan, null);
}
private InboundPlanResponse detail(String customerId, InboundPlan plan,
WarehouseResourceGateway.WarehouseReference resolvedWarehouse) {
if (customerId != null && !customerId.equals(plan.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse(
plan.getCustomerId(), plan.getSourceOrderId());
if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null
|| trimToNull(receivingWarehouse.overseasWarehouseCode()) == null
|| trimToNull(receivingWarehouse.overseasWarehouseName()) == null) {
if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实");
}
WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null
? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse;
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
@@ -642,7 +651,7 @@ public class InboundPlanService {
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
plan.getContainerNo(), receivingWarehouse.overseasWarehouseCode(), receivingWarehouse.overseasWarehouseName(),
plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(),
allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(),
@@ -663,6 +672,30 @@ public class InboundPlanService {
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark());
}
private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) {
String stableId = trimToNull(warehouseId);
if (stableId == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配");
}
try {
return warehouseGateway.requireActiveSharedWarehouse(Long.parseLong(stableId));
} catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓ID格式无效", exception);
}
}
private String requireSingleWarehouseId(List<String> warehouseIds) {
List<String> normalized = warehouseIds.stream().map(InboundPlanService::trimToNull).toList();
if (normalized.stream().anyMatch(Objects::isNull)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在未分配接柜仓的尾程订单");
}
java.util.Set<String> distinct = new java.util.HashSet<>(normalized);
if (distinct.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单接柜仓不一致");
}
return distinct.iterator().next();
}
private BigDecimal volume(SourceOrderSnapshot order) {
return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO);
}
@@ -1856,16 +1856,13 @@ components:
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
ManualInboundPlanRequest:
type: object
required: [customerId, orderId, expectedOrderVersion, plannedWarehouseId, plannedWarehouseCode, plannedWarehouseName, plannedArrivalDate, inboundCode, palletCount, palletType, assigned, mergeRequired, urgent]
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。
required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: integer, format: int64, minimum: 1 }
expectedOrderVersion: { type: integer, minimum: 0 }
plannedWarehouseId: { type: integer, format: int64, minimum: 1 }
plannedWarehouseCode: { type: string, minLength: 1, maxLength: 64 }
plannedWarehouseName: { type: string, minLength: 1, maxLength: 255 }
plannedArrivalDate: { type: string, format: date }
inboundCode: { type: string, minLength: 1, maxLength: 128 }
palletCount: { type: integer, minimum: 1, maximum: 999 }
palletType: { type: string, minLength: 1, maxLength: 64 }
assigned: { type: boolean }
@@ -1,5 +1,6 @@
package com.baiyue.lastleg.inboundplan;
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
@@ -16,6 +17,15 @@ class TailPlanCommandOpenApiContractTest {
String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:"));
assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:"));
assertFalse(api.contains("plannedWarehouseId"));
assertFalse(api.contains("plannedWarehouseCode"));
assertFalse(api.contains("plannedWarehouseName"));
int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:");
int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart);
assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart);
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode"));
assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents())
.anyMatch(component -> "inboundCode".equals(component.getName())));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
@@ -101,7 +101,6 @@ class InboundPlanControllerTest {
private static ManualInboundPlanRequest manualRequest() {
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 8),
"FBA-001", 2, "STANDARD", false, true, true, "加急入仓");
LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓");
}
}
@@ -34,4 +34,17 @@ class InboundPlanOrderMapperContractTest {
assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}"));
assertTrue(!sql.contains("destination_warehouse"));
}
@Test
void manualPlanLocksEveryOrderInTheSelectedStableContainer() throws Exception {
Select select = InboundPlanOrderMapper.class
.getMethod("lockContainerWarehouseFacts", String.class, Long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("c.source_container_booking_id=target.source_container_booking_id"));
assertTrue(sql.contains("target.customer_id=#{customerId} AND target.id=#{orderId}"));
assertTrue(sql.contains("c.overseas_warehouse_id AS overseasWarehouseId"));
assertTrue(sql.contains("ORDER BY c.customer_id,c.id FOR UPDATE"));
}
}
@@ -78,6 +78,12 @@ class InboundPlanServiceTest {
org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot(
"193000000000200003", "US-LAX-01", "洛杉矶仓"));
org.mockito.Mockito.lenient().when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerWarehouseRef(
"100", 2L, "BOOKING-1", "193000000000200003")));
}
@Test
@@ -133,8 +139,7 @@ class InboundPlanServiceTest {
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()))
.thenReturn(1);
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 15),
"RK-2", 2, "STANDARD", true, true, true, "优先收货");
LocalDate.of(2026, 9, 15), 2, "STANDARD", true, true, true, "优先收货");
var result = service.convertManual(request, "manual-2", "900");
@@ -149,6 +154,46 @@ class InboundPlanServiceTest {
verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any());
verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
verify(orderMapper, never()).lockContainerOrders(any());
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
}
@Test
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0,
"193000000000200003", null, null, null,
"WH-A", "WH-A", "仓A", 1, 0, false, 0);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(
193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE"));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(3), eq(false), eq(0), any()))
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 3, "STANDARD", true, false, false, null),
"manual-id-only", "900");
assertEquals("US-LAX-01", result.overseasWarehouseCode());
assertEquals("洛杉矶仓", result.overseasWarehouseName());
verify(warehouseGateway).requireActiveSharedWarehouse(193000000000200003L);
verify(planMapper).insert(any(InboundPlan.class));
}
@Test
void manualPlanRejectsMixedContainerWarehousesBeforeLockingOrderOrWriting() {
when(orderMapper.lockContainerWarehouseFacts("100", 2L)).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerWarehouseRef("100", 2L, "BOOKING-1", "193000000000200003"),
new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004")));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
1, "STANDARD", false, false, false, null), "mixed-warehouse", "900"));
assertEquals(409, error.getStatusCode().value());
verify(orderMapper, never()).lockOrder(any(), any());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
@@ -160,8 +205,8 @@ class InboundPlanServiceTest {
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 5, 193000000000200003L, "US-LAX-01", "洛杉矶仓",
LocalDate.of(2026, 9, 15), "RK-2", 1, "STANDARD", false, false, false, null),
new ManualInboundPlanRequest("100", 2L, 5, LocalDate.of(2026, 9, 15),
1, "STANDARD", false, false, false, null),
"manual-held", "900"));
assertEquals(409, error.getStatusCode().value());
@@ -217,6 +262,8 @@ class InboundPlanServiceTest {
when(planMapper.selectOne(any())).thenReturn(plan);
when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn(
new InboundPlanOrderMapper.ReceivingWarehouseSnapshot("88", "US-NY-01", "纽约仓"));
when(warehouseGateway.requireActiveSharedWarehouse(88L)).thenReturn(
new WarehouseResourceGateway.WarehouseReference(88L, "US-NY-01", "纽约仓", "ACTIVE"));
var result = service.detailByPlanNo("100", "LIP-1");
@@ -440,6 +487,26 @@ class InboundPlanServiceTest {
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
}
@Test
void containerConversionRejectsMixedWarehouseSnapshotsBeforeCreatingAnyPlan() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A")));
SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "200", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0,
"193000000000200004", null, null, null, "WH-B", "WH-B", "仓B", 1, 0, false, 0);
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(differentWarehouse));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.convertContainer("BOOKING-1"));
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
}
@Test
void completeContainerReplayReturnsStableExistingPlansWithoutWriting() {
var refs = List.of(