diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 7b64e2c43..43f5733ac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -117,6 +117,8 @@ public class DispatchPdaWorkflowService { } long picked = picks.totalItem(customerId, task.getId(), itemId); if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + pickAttribution.preparePicking(customerId, request.positionId(), + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index baae29fb7..d1207b9cb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -25,6 +25,25 @@ public class DispatchPickOrderAttributionService { @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper history; + /** Acquire source order locks before any inventory lock, matching order hold/release. */ + @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void preparePicking(String customerId, long positionId, Long fallbackOrderId) { + var sourceIds = new java.util.TreeSet(); + attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); + if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId); + if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order"); + for (Long orderId : sourceIds) { + LastlegOrder order = orders.lockById(customerId, orderId); + if (order == null || Boolean.TRUE.equals(order.getDetained())) { + throw conflict("PDA pick source order is missing or held"); + } + } + if (attributions.availableReceipts(customerId, positionId).stream() + .anyMatch(receipt -> !sourceIds.contains(receipt.getOrderId()))) { + throw conflict("PDA pick source orders changed; reload the task"); + } + } + /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ public List attribute(String customerId, DispatchPdaPick pick) { return attribute(customerId, pick, null); 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 8219e9a67..3bac8a9b2 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 @@ -144,23 +144,25 @@ public class InventoryCommandService { @Transactional public void holdInboundOrderInventory(String customerId, String operatorId, long inboundOrderId, - String businessOrderNo, String remark) { + String businessOrderNo, String remark, String holdCycle) { for (InventoryPosition position : commands.lockInboundAvailablePositions(customerId, inboundOrderId)) { long packages = value(position.getAvailablePackages()); if (packages == 0) continue; hold(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages, - businessOrderNo, null, null, "ORDER_HOLD:" + inboundOrderId + ":" + position.getId(), remark)); + businessOrderNo, null, holdCycle, holdCycle + ":" + position.getId(), remark)); } } @Transactional public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId, - String businessOrderNo, String remark) { - for (InventoryPosition position : commands.lockInboundHeldPositions(customerId, inboundOrderId)) { - long packages = value(position.getHoldPackages()); - if (packages == 0) continue; - release(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages, - businessOrderNo, null, null, "ORDER_HOLD_RELEASE:" + inboundOrderId + ":" + position.getId(), remark)); + String businessOrderNo, String remark, String holdCycle) { + // HOLD is shared by several business flows. Only release facts owned by this order hold cycle. + for (InventoryLedger held : ledgers.orderHoldCycle(customerId, inboundOrderId, holdCycle)) { + if (held.getPositionId() == null || value(held.getDeltaPackages()) <= 0) { + throw conflict("Order hold inventory fact is invalid"); + } + release(customerId, operatorId, new InventoryCommandRequests.Move(held.getPositionId(), held.getDeltaPackages(), + businessOrderNo, null, holdCycle, holdCycle + ":RELEASE:" + held.getPositionId(), remark)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java index cb39de2de..82286989e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -12,6 +12,11 @@ import java.util.List; @Mapper public interface InventoryLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} " + + "AND business_type='HOLD' AND source_event_id=#{holdCycle} ORDER BY position_id,id") + List orderHoldCycle(@Param("customerId") String customerId, + @Param("inboundOrderId") long inboundOrderId, @Param("holdCycle") String holdCycle); + @Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") InventoryLedger findIdempotency(@Param("customerId") String customerId, @Param("idempotencyKey") String idempotencyKey); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java index f85571490..8fc8c86f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java @@ -78,6 +78,15 @@ public class LastlegOrderCommandService { || text(order.getOutboundOrderNo()) != null)) { throw conflict("尾程订单已进入出库流程,不能新增暂扣"); } + String holdCycle = "ORDER_HOLD:" + orderId + ":" + (version + 1); + if (!held) { + LastlegOrderHoldHistory original = holdHistory.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrderHoldHistory::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderHoldHistory::getOrderId, orderId).eq(LastlegOrderHoldHistory::getHeld, true) + .orderByDesc(LastlegOrderHoldHistory::getOrderVersion).last("LIMIT 1")); + if (original == null) throw conflict("暂扣来源记录缺失,不能释放其他业务冻结库存"); + holdCycle = "ORDER_HOLD:" + orderId + ":" + original.getOrderVersion(); + } LocalDateTime now = LocalDateTime.now(); boolean chargeable = held && request.chargeable(); String reason = request.reason().trim(); @@ -86,10 +95,10 @@ public class LastlegOrderCommandService { } if (held) { inventory.holdInboundOrderInventory(order.getCustomerId(), operatorId, orderId, - order.getOrderNo(), reason); + order.getOrderNo(), reason, holdCycle); } else { inventory.releaseInboundOrderHold(order.getCustomerId(), operatorId, orderId, - order.getOrderNo(), reason); + order.getOrderNo(), reason, holdCycle); } int nextVersion = version + 1; LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java index 709a2c49b..3a7bc627f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -23,6 +23,7 @@ import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -80,6 +81,33 @@ class DispatchPickOrderAttributionServiceTest { && "PARTIALLY_SHIPPED".equals(value.getToStatus()) && value.getRemark().contains("6/10"))); } + @Test void pickingLocksDistinctSourcesInOrderBeforeRecheckingReceipts() { + when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(2L,102L,1L,1L), + receipt(1L,101L,1L,1L),receipt(3L,102L,1L,1L))); + when(orders.lockById(eq("t1"),anyLong())).thenAnswer(call -> new LastlegOrder()); + service().preparePicking("t1",91L,null); + var sequence=inOrder(attributions,orders); + sequence.verify(attributions).availableReceipts("t1",91L); + sequence.verify(orders).lockById("t1",101L);sequence.verify(orders).lockById("t1",102L); + sequence.verify(attributions).availableReceipts("t1",91L); + verify(orders,times(2)).lockById(eq("t1"),anyLong()); + } + @Test void heldOrMissingSourcePreventsPickingPreparation() { + when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L))); + var held=new LastlegOrder();held.setDetained(true); + when(orders.lockById("t1",101L)).thenReturn(held).thenReturn(null); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + verifyNoInteractions(history); + } + @Test void changedSourceSetAfterLockingFailsClosed() { + when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L))) + .thenReturn(List.of(receipt(2L,102L,1L,1L))); + when(orders.lockById("t1",101L)).thenReturn(new LastlegOrder()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong()); + } + private DispatchPickOrderAttributionService service() { DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); ReflectionTestUtils.setField(service, "attributions", attributions); 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 1e450ed8d..7a7110092 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 @@ -292,7 +292,7 @@ class InventoryCommandServiceTest { when(commands.lockPosition("100", 201L)).thenReturn(position); when(commands.lockBalance("100", 200L)).thenReturn(balance); - service.holdInboundOrderInventory("100", "7", 101L, "ORD-1", "质检暂扣"); + service.holdInboundOrderInventory("100", "7", 101L, "ORD-1", "质检暂扣", "ORDER_HOLD:101:1"); assertEquals(0L, position.getAvailablePackages()); assertEquals(5L, position.getHoldPackages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java new file mode 100644 index 000000000..217dbe046 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -0,0 +1,224 @@ +package com.baiyue.lastleg.inventory.command; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.service.LastlegOrderCommandService; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Modifier; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** Real local SQL/transactions. Exercises the inventory + source-order boundary used by PDA picking, + * not the fleet/dispatch controller, warehouse remote transactions or TEST MySQL. */ +class InventoryHoldConcurrencyPersistenceTest { + private JdbcTemplate jdbc; + private TransactionTemplate tx; + private InventoryCommandService inventory; + private LastlegOrderCommandService orders; + private LastlegOrderMapper orderMapper; + private DispatchPickOrderAttributionService attribution; + + @BeforeEach void setup() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:hold-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000", "sa", ""); + jdbc = new JdbcTemplate(ds); + for (Class entity : List.of(InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, + InventoryOperation.class, LastlegOrder.class, LastlegOrderCommandRecord.class, + LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, DispatchPdaPickOrder.class)) { + String columns = Arrays.stream(entity.getDeclaredFields()) + .filter(f -> !Modifier.isStatic(f.getModifiers())) + .filter(f -> f.getAnnotation(TableField.class) == null || f.getAnnotation(TableField.class).exist()) + .map(f -> f.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " " + + (f.getType() == String.class ? "VARCHAR(16000)" : f.getType() == LocalDateTime.class ? "TIMESTAMP" + : f.getType() == java.math.BigDecimal.class ? "DECIMAL(24,6)" : "BIGINT") + + (f.getName().equals("id") ? " PRIMARY KEY" : "")) + .collect(Collectors.joining(",")); + jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"); + } + jdbc.execute("CREATE UNIQUE INDEX operation_key ON ll_inventory_operation(customer_id,idempotency_key)"); + var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + for (Class mapper : List.of(InventoryBalanceMapper.class, InventoryPositionMapper.class, + InventoryLedgerMapper.class, InventoryOperationMapper.class, InventoryCommandMapper.class, + LastlegOrderMapper.class, LastlegOrderCommandRecordMapper.class, LastlegOrderHoldHistoryMapper.class, + LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class)) config.addMapper(mapper); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var session = new SqlSessionTemplate(factory.getObject()); + var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); + var stock = new InventoryCommandService(); + ReflectionTestUtils.setField(stock,"balances",session.getMapper(InventoryBalanceMapper.class)); + ReflectionTestUtils.setField(stock,"positions",session.getMapper(InventoryPositionMapper.class)); + ReflectionTestUtils.setField(stock,"ledgers",session.getMapper(InventoryLedgerMapper.class)); + ReflectionTestUtils.setField(stock,"operations",session.getMapper(InventoryOperationMapper.class)); + ReflectionTestUtils.setField(stock,"commands",session.getMapper(InventoryCommandMapper.class)); + ReflectionTestUtils.setField(stock,"warehouseGateway",mock(WarehouseResourceGateway.class)); + ReflectionTestUtils.setField(stock,"objectMapper",new ObjectMapper().findAndRegisterModules()); + inventory = proxy(stock,manager); + orderMapper = session.getMapper(LastlegOrderMapper.class); + var sourceLocks = new DispatchPickOrderAttributionService(); + ReflectionTestUtils.setField(sourceLocks,"orders",orderMapper); + ReflectionTestUtils.setField(sourceLocks,"attributions",session.getMapper(DispatchPdaPickOrderMapper.class)); + attribution = proxy(sourceLocks,manager); + var target = new LastlegOrderCommandService(); + ReflectionTestUtils.setField(target,"orders",orderMapper); + ReflectionTestUtils.setField(target,"commands",session.getMapper(LastlegOrderCommandRecordMapper.class)); + ReflectionTestUtils.setField(target,"holdHistory",session.getMapper(LastlegOrderHoldHistoryMapper.class)); + ReflectionTestUtils.setField(target,"operationLogs",session.getMapper(LastlegOrderOperationLogMapper.class)); + ReflectionTestUtils.setField(target,"inventory",inventory); + ReflectionTestUtils.setField(target,"objectMapper",new ObjectMapper().findAndRegisterModules()); + orders = proxy(target,manager); + jdbc.update("INSERT INTO ll_order(id,customer_id,order_no,current_status,package_count,picked_package_count,shipped_package_count,detained,version) VALUES(11,'C1','ORDER-1','INBOUND_COMPLETED',12,0,0,0,0)"); + jdbc.update("INSERT INTO ll_inventory_balance(id,customer_id,overseas_warehouse_id,inbound_order_id,inbound_code,sku_code,available_packages,hold_packages,allocated_packages,picked_packages,in_transit_packages,receiving_packages,procurement_packages,version) VALUES(20,'C1','WH1',11,'IN-1','SKU',12,0,0,0,0,0,0,0)"); + for (long id : new long[]{21,22}) jdbc.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,batch_no,status,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,'C1',20,?,'PALLET','BATCH','OCCUPIED',?,0,0,0,0,0)",id,id,id==21?5:7); + jdbc.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,inventory_position_id,received_packages,received_at) VALUES(31,'C1',11,21,5,CURRENT_TIMESTAMP),(32,'C1',11,22,7,CURRENT_TIMESTAMP)"); + } + @SuppressWarnings("unchecked") private T proxy(T target, DataSourceTransactionManager manager) { + var factory = new ProxyFactory(target); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource())); + return (T) factory.getProxy(); + } + private void hold(boolean held,int version,String key) { + orders.hold("C1",11,key,new LastlegOrderCommandDtos.HoldRequest(held,false,"quality",version),"operator","Operator"); + } + private InventoryCommandRequests.Move move(long position,long packages,String key) { + return new InventoryCommandRequests.Move(position,packages,"DISPATCH-1","DISPATCH-1",null,key,"fixture"); + } + private void scan(Runnable afterStock) { + tx.executeWithoutResult(ignored -> { + attribution.preparePicking("C1",21,null); + inventory.allocate("C1","picker",move(21,5,"scan-allocate")); + inventory.pick("C1","picker",move(21,5,"scan-pick")); + inventory.ship("C1","picker",move(21,5,"scan-ship")); + afterStock.run(); + // Same production source-order update used by DispatchPickOrderAttributionService. + if (orderMapper.increasePicked("C1",11L,5) != 1) throw new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"source held"); + }); + } + private long amount(String column) { return jdbc.queryForObject("SELECT " + column + " FROM ll_inventory_balance WHERE id=20",Long.class); } + private long count(String table) { return jdbc.queryForObject("SELECT COUNT(*) FROM " + table,Long.class); } + private void conserved(long shipped) { + for (String column : List.of("available_packages","hold_packages","allocated_packages","picked_packages")) { + assertEquals(amount(column),jdbc.queryForObject("SELECT SUM("+column+") FROM ll_inventory_position WHERE balance_id=20",Long.class)); + assertTrue(amount(column)>=0); + } + assertEquals(12,amount("available_packages")+amount("hold_packages")+amount("allocated_packages")+amount("picked_packages")+shipped); + assertEquals(shipped,jdbc.queryForObject("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger WHERE business_type='SHIP'",Long.class)); + } + @Test void secondHoldCycleMustMoveInventoryAgainAndNotReuseTheFirstCycle() { + hold(true,0,"hold-one"); hold(false,1,"release-one"); hold(true,2,"hold-two"); + assertEquals(0,amount("available_packages")); assertEquals(12,amount("hold_packages")); + hold(true,2,"hold-two"); // Original command replay remains harmless. + assertEquals(6,count("ll_inventory_operation")); conserved(0); + hold(false,3,"release-two"); + assertEquals(12,amount("available_packages"));assertEquals(0,amount("hold_packages")); + assertEquals(8,count("ll_inventory_operation"));conserved(0); + } + @Test void releasingOrderHoldMustPreserveAnotherBusinessHold() { + inventory.hold("C1","operator",move(21,2,"RELABEL-HOLD")); + hold(true,0,"order-hold"); hold(false,1,"order-release"); + assertEquals(2,amount("hold_packages")); assertEquals(10,amount("available_packages")); conserved(0); + } + @Test void secondPositionLedgerFailureRollsBackOrderStateAndFirstPosition() { + jdbc.execute("ALTER TABLE ll_inventory_ledger ADD CONSTRAINT reject_second CHECK(position_id<>22)"); + assertThrows(RuntimeException.class,()->hold(true,0,"failed-hold")); + assertEquals(12,amount("available_packages")); assertEquals(0,amount("hold_packages")); + assertEquals(0,count("ll_inventory_operation")); assertEquals(0,count("ll_inventory_ledger")); + assertEquals(0,count("ll_order_hold_history")); assertEquals(0,count("ll_order_command_record")); + assertFalse(Boolean.TRUE.equals(orderMapper.selectById(11L).getDetained())); conserved(0); + } + @Test void failedReleaseRollsBackEarlierReleaseAndKeepsTheOrderHeld() { + hold(true,0,"hold-before-failed-release"); + jdbc.execute("ALTER TABLE ll_inventory_ledger ADD CONSTRAINT reject_release CHECK(business_type<>'RELEASE' OR position_id<>22)"); + assertThrows(RuntimeException.class,()->hold(false,1,"failed-release")); + assertEquals(12,amount("hold_packages"));assertEquals(0,amount("available_packages")); + assertEquals(2,count("ll_inventory_operation"));assertEquals(2,count("ll_inventory_ledger")); + assertEquals(1,count("ll_order_hold_history"));assertTrue(orderMapper.selectById(11L).getDetained());conserved(0); + } + @Test void committedHoldMakesConcurrentScanFailWithoutPartialInventoryWrites() throws Exception { + var held = new CountDownLatch(1); var finish = new CountDownLatch(1); var entered = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future holding=pool.submit(()->tx.executeWithoutResult(s->{hold(true,0,"hold-first");held.countDown();await(finish);})); + await(held); + Future scanning=pool.submit(()->capture(()->{entered.countDown();scan(()->{});})); + await(entered); finish.countDown(); holding.get(8,TimeUnit.SECONDS); + assertInstanceOf(ResponseStatusException.class,scanning.get(8,TimeUnit.SECONDS)); + assertEquals(12,amount("hold_packages")); assertEquals(2,count("ll_inventory_operation")); conserved(0); + } finally { finish.countDown();pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS)); } + } + @Test void committedScanMakesConcurrentHoldFailAndOnlyShipsOnce() throws Exception { + var picked=new CountDownLatch(1);var finish=new CountDownLatch(1);var entered=new CountDownLatch(1); + ExecutorService pool=Executors.newFixedThreadPool(2); + try { + Future scanning=pool.submit(()->tx.executeWithoutResult(s->{scan(()->{});picked.countDown();await(finish);})); + await(picked); + Future holding=pool.submit(()->capture(()->{entered.countDown();hold(true,0,"hold-after-scan");})); + await(entered);finish.countDown();scanning.get(8,TimeUnit.SECONDS); + assertInstanceOf(ResponseStatusException.class,holding.get(8,TimeUnit.SECONDS)); + assertEquals(7,amount("available_packages"));assertEquals(0,count("ll_order_hold_history"));conserved(5); + } finally {finish.countDown();pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));} + } + @Test void concurrentReleaseAndScanPreserveOtherBusinessHold() throws Exception { + inventory.hold("C1","operator",move(22,2,"OTHER-HOLD"));hold(true,0,"order-held"); + var released=new CountDownLatch(1);var finish=new CountDownLatch(1);var entered=new CountDownLatch(1); + ExecutorService pool=Executors.newFixedThreadPool(2); + try { + Future releasing=pool.submit(()->tx.executeWithoutResult(s->{hold(false,1,"release-before-scan");released.countDown();await(finish);})); + await(released); + Future scanning=pool.submit(()->capture(()->{entered.countDown();scan(()->{});})); + await(entered);finish.countDown();releasing.get(8,TimeUnit.SECONDS); + assertNull(scanning.get(8,TimeUnit.SECONDS)); + assertEquals(5,amount("available_packages"));assertEquals(2,amount("hold_packages"));conserved(5); + } finally {finish.countDown();pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));} + } + @Test void overlappingHoldAndScanSerializeOnSourceOrderBeforeTouchingStock() throws Exception { + var orderLocked=new CountDownLatch(1);var scanEntering=new CountDownLatch(1); + ExecutorService pool=Executors.newFixedThreadPool(2); + try { + Future holding=pool.submit(()->capture(()->tx.executeWithoutResult(s->{ + orderMapper.lockById("C1",11L);orderLocked.countDown();await(scanEntering); + hold(true,0,"crossing-hold");}))); + await(orderLocked); + Future scanning=pool.submit(()->capture(()->{scanEntering.countDown();scan(()->{});})); + Throwable scanError=scanning.get(10,TimeUnit.SECONDS),holdError=holding.get(10,TimeUnit.SECONDS); + assertNull(holdError,"hold technical failure: "+holdError); + assertInstanceOf(ResponseStatusException.class,scanError); + assertEquals(12,amount("hold_packages"));conserved(0); + } finally {scanEntering.countDown();pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));} + } + private static Throwable capture(Runnable action) {try{action.run();return null;}catch(RuntimeException failure){return failure;}} + private static void await(CountDownLatch gate) {try{assertTrue(gate.await(6,TimeUnit.SECONDS),"transaction gate timed out");}catch(InterruptedException e){Thread.currentThread().interrupt();throw new IllegalStateException(e);}} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java index 68bef35ed..456183890 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java @@ -84,7 +84,7 @@ class LastlegOrderCommandServiceTest { verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any()); verify(holdHistory).insert(any(LastlegOrderHoldHistory.class)); verify(commands).insert(any(LastlegOrderCommandRecord.class)); - verify(inventory).holdInboundOrderInventory("100", "900", 11L, "LL-11", "海关查验"); + verify(inventory).holdInboundOrderInventory("100", "900", 11L, "LL-11", "海关查验", "ORDER_HOLD:11:4"); verify(orders, never()).lockContainerOrders(any()); } @@ -93,6 +93,8 @@ class LastlegOrderCommandServiceTest { LastlegOrder order = order(11L, 3); order.setCurrentStatus("INBOUND_COMPLETED"); order.setDetained(true); + var originalHold = new LastlegOrderHoldHistory(); originalHold.setOrderVersion(2); + when(holdHistory.selectOne(any())).thenReturn(originalHold); when(orders.selectById(11L)).thenReturn(order); when(orders.lockById("100", 11L)).thenReturn(order); when(orders.updateHold(eq("100"), eq(11L), eq(false), eq(false), eq("质检通过"), eq(3), @@ -101,7 +103,7 @@ class LastlegOrderCommandServiceTest { service.hold("100", 11L, "release-11", new LastlegOrderCommandDtos.HoldRequest(false, false, "质检通过", 3), "900", "管理员"); - verify(inventory).releaseInboundOrderHold("100", "900", 11L, "LL-11", "质检通过"); + verify(inventory).releaseInboundOrderHold("100", "900", 11L, "LL-11", "质检通过", "ORDER_HOLD:11:2"); } @Test