diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a1f20dcae..5e7faa8cf 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -751,6 +751,14 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''', 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code')); PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_name'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='customer_name'), + 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''', + 'ALTER TABLE ll_inventory_ledger ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java index b50e7eabf..0cfc25de9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; + @Mapper public interface ReceivingContainerSessionMapper extends BaseMapper { @Select("SELECT * FROM ll_receiving_container_session " + @@ -16,6 +18,13 @@ public interface ReceivingContainerSessionMapper extends BaseMapper findActiveContainers(); + @Select("SELECT * FROM ll_receiving_container_session " + "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 114d20195..58ee791f0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -194,11 +194,22 @@ public class InboundStockReceiptService { } if (destinationCode == null) destinationCode = ""; String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); + String customerName = required(order.getCustomerName(), + "Customer name snapshot is required for stock-in"); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); + String overseasWarehouseName = required(order.getOverseasWarehouseName(), + "Actual overseas warehouse name is required for stock-in"); + if (plan.getOverseasWarehouseId() == null + || !overseasWarehouseId.equals(String.valueOf(plan.getOverseasWarehouseId()))) { + throw conflict("Inbound plan and tail-leg order overseas warehouses must match for stock-in"); + } + if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) { + throw conflict("Actual overseas warehouse must not use the domestic source warehouse"); + } InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(), - new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, - null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, + new InventoryCommandRequests.StockIn(null, customerName, overseasWarehouseId, + null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode, line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), sku.skuCode(), sku.productNameZh(), sku.productNameEn(), pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 195326cd6..e777c24ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -103,7 +103,14 @@ public class ReceivingContainerService { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); - return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> { + String operatorLockKey = activeOperatorKey(operatorKey); + Set reservedByOthers = sessions.findActiveContainers().stream() + .filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey())) + .map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet()); + return grouped.entrySet().stream() + .filter(entry -> eligible(entry.getValue(), overseasWarehouseId)) + .filter(entry -> !reservedByOthers.contains(activeContainerKey(entry.getKey()))) + .map(entry -> { requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); @@ -123,6 +130,10 @@ public class ReceivingContainerService { if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } + ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo)); + if (reserved != null && !activeOperatorKey(operatorKey).equals(reserved.getActiveOperatorKey())) { + throw conflict("Container is already signed or prepared by another account"); + } WarehouseResourceGateway.WarehouseReference receivingWarehouse = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java index 2773296fa..6f7357a46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -12,7 +12,7 @@ public final class InventoryCommandRequests { public record StockIn( String customerId, - String customerName, + @NotBlank @Size(max = 255) String customerName, @NotBlank String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, @@ -39,7 +39,7 @@ public final class InventoryCommandRequests { } public record InTransit( - String customerName, + @NotBlank @Size(max = 255) String customerName, @NotBlank String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index f42015986..21b72b25a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -232,6 +232,7 @@ public class InventoryCommandService { String destinationWarehouseName, Long inboundOrderId, String inboundOrderNo, String inboundCode, String requestedSkuCode, String productNameZh, String productNameEn) { + String frozenCustomerName = required(customerName, "Customer name snapshot is required"); String skuCode = sku(requestedSkuCode); String destinationId = trim(destinationWarehouseId); String destinationCode = trim(destinationWarehouseCode); @@ -242,13 +243,14 @@ public class InventoryCommandService { InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, destinationCode, inboundOrderId, inboundCode); if (balance != null) { + requireCustomerSnapshot(balance, frozenCustomerName); mergeSkuSnapshot(balance, skuCode); return balance; } balance = new InventoryBalance(); balance.setId(IdWorker.getId()); balance.setCustomerId(customerId); - balance.setCustomerName(trim(customerName)); + balance.setCustomerName(frozenCustomerName); balance.setOverseasWarehouseId(overseasWarehouseId.trim()); balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); balance.setOverseasWarehouseName(trim(overseasWarehouseName)); @@ -272,6 +274,7 @@ public class InventoryCommandService { InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, destinationCode, inboundOrderId, inboundCode); if (concurrent != null) { + requireCustomerSnapshot(concurrent, frozenCustomerName); mergeSkuSnapshot(concurrent, skuCode); return concurrent; } @@ -384,6 +387,7 @@ public class InventoryCommandService { InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId()); ledger.setCustomerId(customerId); + ledger.setCustomerName(balance.getCustomerName()); ledger.setBalanceId(balance.getId()); ledger.setPositionId(position == null ? null : position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); @@ -515,6 +519,16 @@ public class InventoryCommandService { if (actual < requested) throw conflict("Insufficient " + field + " packages"); } + private static void requireCustomerSnapshot(InventoryBalance balance, String requestedCustomerName) { + String frozenCustomerName = trim(balance.getCustomerName()); + if (frozenCustomerName == null) { + throw conflict("Inventory balance customer name snapshot is missing"); + } + if (!frozenCustomerName.equals(requestedCustomerName)) { + throw conflict("Inventory balance customer name snapshot does not match"); + } + } + private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java index 4ffeff2cd..cdcb2d5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -15,6 +15,7 @@ public class InventoryLedger { @TableId(type = IdType.ASSIGN_ID) private Long id; private String customerId; + private String customerName; private Long balanceId; private Long positionId; private String overseasWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java index 7f60e2f0b..f4f60e6d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; public record InventoryLedgerView( Long id, String customerId, + String customerName, String overseasWarehouseId, String destinationWarehouseCode, Long inboundOrderId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index eb6bcab9a..b61fb98db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -97,11 +97,11 @@ public class InventoryQueryService { public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { validateDateRange(query); List rows = new ArrayList<>(); - rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + rows.add("occurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + "beforePackages,deltaPackages,afterPackages,operatorId,remark"); mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( - String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), + String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), @@ -144,7 +144,7 @@ public class InventoryQueryService { } private InventoryLedgerView ledgerView(InventoryLedger value) { - return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(), + return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationCode(), value.getSkuCode(), value.getBusinessType(), value.getBusinessOrderNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 34be04951..5204fe1fb 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -863,7 +863,7 @@ ALTER TABLE ll_loading_order_status_history CREATE TABLE IF NOT EXISTS ll_inventory_balance ( id BIGINT NOT NULL COMMENT '主键ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称不可变快照', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', @@ -908,7 +908,7 @@ CREATE TABLE IF NOT EXISTS ll_inventory_position ( ) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( - id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NOT NULL COMMENT '客户名称不可变快照', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index d856e2e58..65ee6556a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -83,6 +83,9 @@ class InboundStockReceiptServiceTest { assertEquals(101L, request.getValue().inboundOrderId()); assertEquals("IN-1", request.getValue().inboundCode()); assertEquals("SKU-1", request.getValue().skuCode()); + assertEquals("Customer", request.getValue().customerName()); + assertEquals("900", request.getValue().overseasWarehouseId()); + assertEquals("Los Angeles", request.getValue().overseasWarehouseName()); ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); verify(receipts).insert(receipt.capture()); assertEquals(5L, receipt.getValue().getReceivedPackages()); @@ -210,16 +213,31 @@ class InboundStockReceiptServiceTest { assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId()); } + @Test + void rejectsDomesticSourceWarehouseAsActualOverseasWarehouse() { + Fixture fixture = fixture(5, 5, 5, 10); + fixture.order.setOverseasWarehouseId(fixture.order.getSourceWarehouseId()); + fixture.plan.setOverseasWarehouseId(Long.valueOf(fixture.order.getSourceWarehouseId())); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task, + List.of(fixture.line), List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now())); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { - InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); + InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L); line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST"); line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8"); line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0")); LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100"); - order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1"); - order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); + order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setSourceWarehouseId("100"); + order.setOverseasWarehouseId("900"); order.setOverseasWarehouseName("Los Angeles"); + order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); order.setCurrentStatus("INBOUND_PLANNED"); org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 8be1cc079..7270ef952 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -155,6 +155,33 @@ class ReceivingContainerServiceBehaviorTest { .containsExactly("CONT-A", "CONT-B"); } + @Test + void candidatesHideContainersReservedByAnotherOperator() { + InboundPlan available = plan("CONT-A"); + InboundPlan reserved = plan("CONT-B"); reserved.setId(3L); + ReceivingContainerSession other = session("CONT-B", "WAIT_SIGN"); + other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); + other.setActiveContainerKey("CONTAINER:CONT-B"); + when(plans.selectList(any())).thenReturn(List.of(available, reserved), List.of(available)); + when(lines.selectList(any())).thenReturn(List.of()); + when(sessions.findActiveContainers()).thenReturn(List.of(other)); + + assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A"); + } + + @Test + void detailRejectsContainerReservedByAnotherOperator() { + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + ReceivingContainerSession other = session("CONT-B", "SIGNED"); + other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); + other.setActiveContainerKey("CONTAINER:CONT-B"); + when(sessions.findActiveByContainer("CONTAINER:CONT-B")).thenReturn(other); + + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(warehouse); + } + @Test void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 5722fc1e3..5e2139074 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -72,8 +72,11 @@ class InventoryCommandServiceTest { verify(balances).updateById(balance.capture()); assertEquals(12L, balance.getValue().getAvailablePackages()); assertEquals("SKU-1", balance.getValue().getSkuCode()); + assertEquals("Customer 100", balance.getValue().getCustomerName()); verify(positions).insert(any(InventoryPosition.class)); - verify(ledgers).insert(any(InventoryLedger.class)); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertEquals("Customer 100", ledger.getValue().getCustomerName()); verify(operations).updateById(any(InventoryOperation.class)); } @@ -152,6 +155,33 @@ class InventoryCommandServiceTest { verify(ledgers).insert(any(InventoryLedger.class)); } + @Test + void stockInRejectsMissingCustomerSnapshotBeforeInventoryMutation() { + InventoryCommandRequests.StockIn request = new InventoryCommandRequests.StockIn( + "100", null, "WH-1", "WH1", "Warehouse 1", "DEST-1", "ONT8", "ONT8", + 101L, "IN-1", "IN-CODE-1", "SKU-1", "Product 1", null, 300L, "PLT-1", null, + null, null, 1, "ORD-1", "EVT-1", "missing-customer", null); + + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", "7", request)); + + verify(balances, never()).insert(any(InventoryBalance.class)); + verify(positions, never()).insert(any(InventoryPosition.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); + } + + @Test + void stockInRejectsCustomerSnapshotDrift() { + InventoryBalance balance = balance(200L, 4); + balance.setCustomerName("Frozen customer"); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(balance); + + assertThrows(ResponseStatusException.class, + () -> service.stockIn("100", "7", stockIn("IN-1", "customer-drift", 1, 1))); + + verify(positions, never()).insert(any(InventoryPosition.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); + } + @Test void holdRejectsWhenAvailablePackagesAreInsufficient() { InventoryBalance balance = balance(200L, 5); @@ -233,7 +263,8 @@ class InventoryCommandServiceTest { private InventoryBalance balance(Long id, long available) { InventoryBalance value = new InventoryBalance(); - value.setId(id); value.setCustomerId("100"); value.setOverseasWarehouseId("WH-1"); + value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100"); + value.setOverseasWarehouseId("WH-1"); value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L); value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1"); value.setAvailablePackages(available);