This commit is contained in:
2026-08-28 10:03:32 +08:00
parent dd117b8fc2
commit a6f266687e
13 changed files with 147 additions and 16 deletions
@@ -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 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')); '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; 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'), 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')); '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; PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
@@ -6,6 +6,8 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> { public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
@Select("SELECT * FROM ll_receiving_container_session " + @Select("SELECT * FROM ll_receiving_container_session " +
@@ -16,6 +18,13 @@ public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingCon
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1") "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1")
ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_container_key=#{activeContainerKey} LIMIT 1")
ReceivingContainerSession findActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
@Select("SELECT * FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL")
List<ReceivingContainerSession> findActiveContainers();
@Select("SELECT * FROM ll_receiving_container_session " + @Select("SELECT * FROM ll_receiving_container_session " +
"WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
@@ -194,11 +194,22 @@ public class InboundStockReceiptService {
} }
if (destinationCode == null) destinationCode = ""; if (destinationCode == null) destinationCode = "";
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); 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(), String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in"); "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(), InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, new InventoryCommandRequests.StockIn(null, customerName, overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(), sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
@@ -103,7 +103,14 @@ public class ReceivingContainerService {
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); 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<String> 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()); requireSingleCustomer(entry.getValue(), entry.getKey());
Totals totals = totals(entry.getValue()); Totals totals = totals(entry.getValue());
boolean active = current != null && entry.getKey().equals(current.getContainerNo()); boolean active = current != null && entry.getKey().equals(current.getContainerNo());
@@ -123,6 +130,10 @@ public class ReceivingContainerService {
if (current != null && !containerNo.equals(current.getContainerNo())) { if (current != null && !containerNo.equals(current.getContainerNo())) {
throw conflict("Current account is locked to another selected container"); 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 = WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId); warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
@@ -12,7 +12,7 @@ public final class InventoryCommandRequests {
public record StockIn( public record StockIn(
String customerId, String customerId,
String customerName, @NotBlank @Size(max = 255) String customerName,
@NotBlank String overseasWarehouseId, @NotBlank String overseasWarehouseId,
String overseasWarehouseCode, String overseasWarehouseCode,
String overseasWarehouseName, String overseasWarehouseName,
@@ -39,7 +39,7 @@ public final class InventoryCommandRequests {
} }
public record InTransit( public record InTransit(
String customerName, @NotBlank @Size(max = 255) String customerName,
@NotBlank String overseasWarehouseId, @NotBlank String overseasWarehouseId,
String overseasWarehouseCode, String overseasWarehouseCode,
String overseasWarehouseName, String overseasWarehouseName,
@@ -232,6 +232,7 @@ public class InventoryCommandService {
String destinationWarehouseName, Long inboundOrderId, String inboundOrderNo, String destinationWarehouseName, Long inboundOrderId, String inboundOrderNo,
String inboundCode, String requestedSkuCode, String productNameZh, String inboundCode, String requestedSkuCode, String productNameZh,
String productNameEn) { String productNameEn) {
String frozenCustomerName = required(customerName, "Customer name snapshot is required");
String skuCode = sku(requestedSkuCode); String skuCode = sku(requestedSkuCode);
String destinationId = trim(destinationWarehouseId); String destinationId = trim(destinationWarehouseId);
String destinationCode = trim(destinationWarehouseCode); String destinationCode = trim(destinationWarehouseCode);
@@ -242,13 +243,14 @@ public class InventoryCommandService {
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
destinationCode, inboundOrderId, inboundCode); destinationCode, inboundOrderId, inboundCode);
if (balance != null) { if (balance != null) {
requireCustomerSnapshot(balance, frozenCustomerName);
mergeSkuSnapshot(balance, skuCode); mergeSkuSnapshot(balance, skuCode);
return balance; return balance;
} }
balance = new InventoryBalance(); balance = new InventoryBalance();
balance.setId(IdWorker.getId()); balance.setId(IdWorker.getId());
balance.setCustomerId(customerId); balance.setCustomerId(customerId);
balance.setCustomerName(trim(customerName)); balance.setCustomerName(frozenCustomerName);
balance.setOverseasWarehouseId(overseasWarehouseId.trim()); balance.setOverseasWarehouseId(overseasWarehouseId.trim());
balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); balance.setOverseasWarehouseCode(trim(overseasWarehouseCode));
balance.setOverseasWarehouseName(trim(overseasWarehouseName)); balance.setOverseasWarehouseName(trim(overseasWarehouseName));
@@ -272,6 +274,7 @@ public class InventoryCommandService {
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
destinationCode, inboundOrderId, inboundCode); destinationCode, inboundOrderId, inboundCode);
if (concurrent != null) { if (concurrent != null) {
requireCustomerSnapshot(concurrent, frozenCustomerName);
mergeSkuSnapshot(concurrent, skuCode); mergeSkuSnapshot(concurrent, skuCode);
return concurrent; return concurrent;
} }
@@ -384,6 +387,7 @@ public class InventoryCommandService {
InventoryLedger ledger = new InventoryLedger(); InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId()); ledger.setId(IdWorker.getId());
ledger.setCustomerId(customerId); ledger.setCustomerId(customerId);
ledger.setCustomerName(balance.getCustomerName());
ledger.setBalanceId(balance.getId()); ledger.setBalanceId(balance.getId());
ledger.setPositionId(position == null ? null : position.getId()); ledger.setPositionId(position == null ? null : position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
@@ -515,6 +519,16 @@ public class InventoryCommandService {
if (actual < requested) throw conflict("Insufficient " + field + " packages"); 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) { private static String required(String value, String message) {
String normalized = trim(value); String normalized = trim(value);
if (normalized == null) throw badRequest(message); if (normalized == null) throw badRequest(message);
@@ -15,6 +15,7 @@ public class InventoryLedger {
@TableId(type = IdType.ASSIGN_ID) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private String customerId; private String customerId;
private String customerName;
private Long balanceId; private Long balanceId;
private Long positionId; private Long positionId;
private String overseasWarehouseId; private String overseasWarehouseId;
@@ -5,6 +5,7 @@ import java.time.LocalDateTime;
public record InventoryLedgerView( public record InventoryLedgerView(
Long id, Long id,
String customerId, String customerId,
String customerName,
String overseasWarehouseId, String overseasWarehouseId,
String destinationWarehouseCode, String destinationWarehouseCode,
Long inboundOrderId, Long inboundOrderId,
@@ -97,11 +97,11 @@ public class InventoryQueryService {
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
validateDateRange(query); validateDateRange(query);
List<String> rows = new ArrayList<>(); List<String> 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," + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark"); "beforePackages,deltaPackages,afterPackages,operatorId,remark");
mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( 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.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.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()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
@@ -144,7 +144,7 @@ public class InventoryQueryService {
} }
private InventoryLedgerView ledgerView(InventoryLedger value) { 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.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(),
value.getPalletNo(), value.getBatchNo(), value.getPalletNo(), value.getBatchNo(),
value.getWarehouseLocationCode(), value.getSkuCode(), value.getBusinessType(), value.getBusinessOrderNo(), value.getWarehouseLocationCode(), value.getSkuCode(), value.getBusinessType(), value.getBusinessOrderNo(),
@@ -863,7 +863,7 @@ ALTER TABLE ll_loading_order_status_history
CREATE TABLE IF NOT EXISTS ll_inventory_balance ( CREATE TABLE IF NOT EXISTS ll_inventory_balance (
id BIGINT NOT NULL COMMENT '主键ID', 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_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
@@ -908,7 +908,7 @@ CREATE TABLE IF NOT EXISTS ll_inventory_position (
) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; ) ENGINE=InnoDB COMMENT='托盘库位批次库存明细';
CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( 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', 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', 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 '库存批次号', pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号',
@@ -83,6 +83,9 @@ class InboundStockReceiptServiceTest {
assertEquals(101L, request.getValue().inboundOrderId()); assertEquals(101L, request.getValue().inboundOrderId());
assertEquals("IN-1", request.getValue().inboundCode()); assertEquals("IN-1", request.getValue().inboundCode());
assertEquals("SKU-1", request.getValue().skuCode()); assertEquals("SKU-1", request.getValue().skuCode());
assertEquals("Customer", request.getValue().customerName());
assertEquals("900", request.getValue().overseasWarehouseId());
assertEquals("Los Angeles", request.getValue().overseasWarehouseName());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture()); verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages()); assertEquals(5L, receipt.getValue().getReceivedPackages());
@@ -210,16 +213,31 @@ class InboundStockReceiptServiceTest {
assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId()); 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) { 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"); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L); InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L);
line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST"); line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST");
line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8"); line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8");
line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0")); line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0"));
LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100"); LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100");
order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1"); order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setSourceWarehouseId("100");
order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); order.setOverseasWarehouseId("900"); order.setOverseasWarehouseName("Los Angeles");
order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8");
order.setCurrentStatus("INBOUND_PLANNED"); order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
@@ -155,6 +155,33 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("CONT-A", "CONT-B"); .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 @Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
@@ -72,8 +72,11 @@ class InventoryCommandServiceTest {
verify(balances).updateById(balance.capture()); verify(balances).updateById(balance.capture());
assertEquals(12L, balance.getValue().getAvailablePackages()); assertEquals(12L, balance.getValue().getAvailablePackages());
assertEquals("SKU-1", balance.getValue().getSkuCode()); assertEquals("SKU-1", balance.getValue().getSkuCode());
assertEquals("Customer 100", balance.getValue().getCustomerName());
verify(positions).insert(any(InventoryPosition.class)); verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class)); ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
verify(ledgers).insert(ledger.capture());
assertEquals("Customer 100", ledger.getValue().getCustomerName());
verify(operations).updateById(any(InventoryOperation.class)); verify(operations).updateById(any(InventoryOperation.class));
} }
@@ -152,6 +155,33 @@ class InventoryCommandServiceTest {
verify(ledgers).insert(any(InventoryLedger.class)); 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 @Test
void holdRejectsWhenAvailablePackagesAreInsufficient() { void holdRejectsWhenAvailablePackagesAreInsufficient() {
InventoryBalance balance = balance(200L, 5); InventoryBalance balance = balance(200L, 5);
@@ -233,7 +263,8 @@ class InventoryCommandServiceTest {
private InventoryBalance balance(Long id, long available) { private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance(); 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.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L);
value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1"); value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1");
value.setAvailablePackages(available); value.setAvailablePackages(available);