This commit is contained in:
2026-10-20 14:22:31 +08:00
parent 19745e46c1
commit a870b920fe
95 changed files with 2616 additions and 275 deletions
@@ -0,0 +1,118 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class RelabelOutboundPickingTest {
private DeliveryTransferService service;
private InventoryCommandService commands;
private InventoryPosition position;
private InventoryBalance balance;
private OutboundPalletItem item;
private OutboundPdaPickMapper picks;
private OutboundPdaTaskMapper tasks;
private OutboundOrderMapper orders;
private OutboundPalletItemMapper pallets;
private InventoryPositionMapper positions;
private InventoryBalanceMapper balances;
private RelabelApplicationMapper applications;
private RelabelItemMapper items;
/** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */
@BeforeEach void prepare() {
service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class);
picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class);
orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class);
positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class);
applications=mock(RelabelApplicationMapper.class); items=mock(RelabelItemMapper.class);
Map.of("inventoryCommands",commands,"pdaPicks",picks,"pdaTasks",tasks,"outboundOrders",orders,
"outboundPallets",pallets,"inventoryPositions",positions,"inventoryBalances",balances,
"relabelApplications",applications,"relabelItems",items,"objectMapper",new ObjectMapper())
.forEach((name,value)->ReflectionTestUtils.setField(service,name,value));
var outbound=new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C");
outbound.setOverseasWarehouseId("W"); outbound.setOutboundNo("OUT"); outbound.setOutboundType("RELABEL_OUTBOUND");
var task=new OutboundPdaTask(); task.setId(2L); task.setCustomerId("C"); task.setOutboundOrderId(1L);
task.setTaskNo("TASK"); task.setStatus("PENDING");
item=new OutboundPalletItem(); item.setId(3L); item.setCustomerId("C"); item.setOutboundOrderId(1L);
item.setWarehousePalletId(4L); item.setPalletNo("NEW-TARGET"); item.setInboundCode("IN");
item.setPackageCount(2); item.setPickedPackageCount(0); item.setCartonMarks("BOX-A,BOX-B");
item.setOrderRefs("""
{"sourceType":"RELABEL_ORDER","relabelNo":"RL","sourceInventoryPositionId":9,
"items":[{"itemId":11,"cartonMark":"BOX-A","packages":1},{"itemId":12,"cartonMark":"BOX-B","packages":1}]}
""");
position=new InventoryPosition(); position.setId(9L); position.setCustomerId("C"); position.setBalanceId(8L);
position.setOverseasWarehouseId("W"); position.setPalletId(4L); position.setPalletNo("NEW-TARGET");
position.setAvailablePackages(0L); position.setHoldPackages(2L);
balance=new InventoryBalance(); balance.setId(8L); balance.setCustomerId("C");
balance.setOverseasWarehouseId("W"); balance.setInboundCode("IN"); balance.setSkuCode("MIXED");
when(tasks.lockByNoAndWarehouse("TASK","W")).thenReturn(task);
when(tasks.findByNoAndWarehouse("TASK","W")).thenReturn(task);
when(orders.selectById(1L)).thenReturn(outbound); when(orders.lockByNo("C","OUT")).thenReturn(outbound);
when(pallets.findByOutbound("C",1L)).thenReturn(List.of(item)); when(pallets.selectById(3L)).thenReturn(item);
when(positions.listPallet("C",4L)).thenReturn(List.of(position)); when(positions.selectById(9L)).thenReturn(position);
when(balances.selectById(8L)).thenReturn(balance);
var app=new RelabelApplication(); app.setId(10L); app.setRelabelNo("RL"); app.setStatus("READY_FOR_OUTBOUND");
when(applications.lockByOutbound("C","OUT")).thenReturn(app);
var first=new RelabelItem(); first.setId(11L); first.setCartonMark("BOX-A"); first.setNotifiedPackageCount(1);
var second=new RelabelItem(); second.setId(12L); second.setCartonMark("BOX-B"); second.setNotifiedPackageCount(1);
when(items.selectList(any())).thenReturn(List.of(first,second));
}
/** 指定箱从冻结到出库数量守恒;整托重扫不重复释放或出库。 */
@Test void picksExactFrozenBoxesAndReplayDoesNotShipAgain() {
assertEquals(List.of("BOX-A","BOX-B"),service.pdaTaskDetail("W","TASK").items().get(0).positions()
.stream().map(value->value.cartonMark()).toList());
service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"));
service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"));
var release=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands,times(2)).release(eq("C"),eq("U"),release.capture());
assertEquals(2,release.getAllValues().stream().mapToLong(InventoryCommandRequests.Move::packages).sum());
assertTrue(release.getAllValues().stream().allMatch(move->move.positionId()==9L&&"RELABEL:RL".equals(move.sourceEventId())));
var shipped=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands).ship(eq("C"),eq("U"),shipped.capture());
assertEquals(2,shipped.getValue().packages()); assertEquals(9L,shipped.getValue().positionId());
verify(commands).allocate(eq("C"),eq("U"),any()); verify(commands).pick(eq("C"),eq("U"),any());
verify(picks).insert(any(OutboundPdaPick.class)); assertEquals(2,item.getPickedPackageCount());
}
/** 其他位置、客户、仓、入仓或不足冻结均拒绝,不能用空SKU放宽范围。 */
@ParameterizedTest @ValueSource(strings={"POSITION","CUSTOMER","WAREHOUSE","INBOUND","HOLD","SNAPSHOT"})
void rejectsUnrelatedOrIncompleteSourcesWithoutInventoryEffects(String invalid) {
switch(invalid) {
case "POSITION" -> position.setId(99L);
case "CUSTOMER" -> balance.setCustomerId("OTHER");
case "WAREHOUSE" -> position.setOverseasWarehouseId("OTHER");
case "INBOUND" -> balance.setInboundCode("OTHER");
case "HOLD" -> position.setHoldPackages(1L);
case "SNAPSHOT" -> item.setOrderRefs("{\"sourceType\":\"RELABEL_ORDER\"}");
}
assertTrue(service.pdaTaskDetail("W","TASK").items().get(0).positions().isEmpty());
assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")));
verifyNoInteractions(commands,picks);
}
/** 同箱唛也不能代替来源任务主键,损坏ID必须在释放冻结前拒绝。 */
@Test void rejectsWrongSnapshotItemIdBeforeReleasingAnyStock() {
item.setOrderRefs(item.getOrderRefs().replace("\"itemId\":11", "\"itemId\":999"));
assertEquals(2,service.pdaTaskDetail("W","TASK").items().get(0).positions().size());
assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")));
verifyNoInteractions(commands); verify(picks,never()).insert(any(OutboundPdaPick.class));
}
}
@@ -16,6 +16,50 @@ import static org.junit.jupiter.api.Assertions.*;
/** 使用真实 SQL 核对转运实收箱与库存仓位的关联及废弃状态流转。 */
class CargoDiscardTransferCartonMapperTest {
/** MIXED 是整托物理余额标记;精确返回剩余实箱及真实 SKU,不能降级成整仓位或混入别的来源。 */
@Test
void mixedPhysicalBalanceKeepsOnlyItsRemainingCountedCartons() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inbound_plan_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),received_at TIMESTAMP,destination_warehouse_code VARCHAR(20),carton_mark VARCHAR(80),sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20),sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,batch_no VARCHAR(80),overseas_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','MIXED')");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'TRANSFER','W'),(42,'C',1,100,'OTHER-BATCH','W'),(43,'C',1,100,'TRANSFER','OTHER-WAREHOUSE')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'TRANSFER','W'),(20,'C',10,'OTHER-ORDER','W')");
db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(11,'C',10,100,'COMPLETED'),(12,'C',10,200,'COMPLETED'),(13,'C',10,100,'BOUND'),(14,'C',20,100,'COMPLETED')");
db.update("INSERT INTO ll_warehouse_transfer_item VALUES(21,'C',10,'A'),(22,'C',10,'B'),(23,'C',20,'FOREIGN-ORDER')");
db.update("INSERT INTO ll_warehouse_transfer_carton(id,customer_id,transfer_order_id,transfer_item_id,transfer_pallet_id,status,carton_mark,sku_items_json) VALUES " +
"(1,'C',10,21,11,'COUNTED','REMAINING-A','[{\"skuCode\":\"A\",\"quantity\":2}]')," +
"(2,'C',10,22,11,'DISPATCHED','MOVED-B','[{\"skuCode\":\"B\",\"quantity\":8}]')," +
"(3,'C',10,21,12,'COUNTED','OTHER-PALLET',NULL),(4,'OTHER',10,21,11,'COUNTED','OTHER-CUSTOMER',NULL)," +
"(5,'C',10,21,11,'EXPECTED','FORECAST',NULL),(6,'C',10,21,11,'DISCARD_PENDING','HELD',NULL)," +
"(7,'C',10,21,13,'COUNTED','NOT-STOCKED',NULL),(8,'C',20,23,14,'COUNTED','OTHER-ORDER',NULL)," +
"(9,'C',10,23,11,'COUNTED','ITEM-ORDER-MISMATCH',NULL)");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
config.setMapUnderscoreToCamelCase(true);
config.addMapper(CargoDiscardInventoryMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
var rows = mapper.genericCartons("C", 41L);
assertEquals(List.of("REMAINING-A"), rows.stream().map(c -> c.getCartonMark()).toList());
assertEquals("A", rows.get(0).getSkuCode());
assertEquals("[{\"skuCode\":\"A\",\"quantity\":2}]", rows.get(0).getSkuItemsJson());
assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty());
assertTrue(mapper.genericCartons("C", 42L).isEmpty());
assertTrue(mapper.genericCartons("C", 43L).isEmpty());
db.update("UPDATE ll_warehouse_transfer_carton SET status='COUNTED',sku_items_json='[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":5}]' WHERE id=2");
session.clearCache();
assertEquals(List.of("REMAINING-A", "MOVED-B"), mapper.genericCartons("C", 41L).stream().map(c -> c.getCartonMark()).toList());
assertEquals("B", mapper.genericCartons("C", 41L).get(1).getSkuCode());
assertTrue(mapper.genericCartons("C", 41L).get(1).getSkuItemsJson().contains("\"quantity\":5"));
}
}
/** 同批多托盘、多 SKU 时仅返回本仓位实收箱,排除预报、短收、占用及其他客户的箱。 */
@Test
void receivedBoxesFollowTheirActualPalletAndSku() {
@@ -27,7 +71,7 @@ class CargoDiscardTransferCartonMapperTest {
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','SKU'),(2,'C',10,'FBA','OTHER')");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'ZY-BYWL-261019194301','W'),(42,'C',1,200,'ZY-BYWL-261019194301','W'),(43,'C',2,100,'ZY-BYWL-261019194301','W')");
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'ZY-BYWL-261019194301','W')");
@@ -48,6 +92,10 @@ class CargoDiscardTransferCartonMapperTest {
assertEquals(List.of("OTHER-PALLET"), mapper.genericCartons("C", 42L).stream().map(c -> c.getCartonMark()).toList());
assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList());
assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty());
db.update("UPDATE ll_warehouse_transfer_carton SET sku_items_json=? WHERE id=1", "[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"OTHER\",\"quantity\":7}]");
session.clearCache();
assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList());
assertTrue(mapper.genericCartons("C", 41L).get(0).getSkuItemsJson().contains("OTHER"));
assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING"));
assertEquals(1, mapper.genericCartons("C", 41L).size());
assertEquals(0, mapper.changeTransferCartonStatus("OTHER", 2L, "DISCARD_PENDING", "COUNTED"));
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.bind.support.WebDataBinderFactory;
import java.time.LocalDateTime;
import java.util.Set;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 使用真实 MVC 参数绑定防止查询构造器重载导致列表和统计全部失败。 */
class CargoDiscardQueryBindingTest {
/** 客户归属取认证身份,完成时间同时传入分页和全筛选统计。 */
@Test void mvcBindsCompletionDatesForPageAndStatistics() throws Exception {
var service = mock(CargoDiscardService.class);
var controller = new MerchantCargoDiscardController();
ReflectionTestUtils.setField(controller, "service", service);
var mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
@Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; }
@Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest request, WebDataBinderFactory binder) {
return new AuthenticatedUser(1L, 100L, 100L, 9L, "fixture", "MERCHANT", "WEB", Set.of());
}
}).build();
for (String suffix : new String[]{"", "/statistics"}) {
mvc.perform(get("/api/merchant/v1/last-leg/cargo-discards" + suffix)
.param("page", "1").param("size", "1").param("customerId", "OTHER")
.param("completedFrom", "2099-01-01T00:00:00")).andExpect(status().isOk());
}
var query = new CargoDiscardRequests.Query(1, 1, null, null, null, null, null, null,
LocalDateTime.of(2099, 1, 1, 0, 0), null);
verify(service).page("100", query);
verify(service).statistics("100", query);
}
}
@@ -42,15 +42,17 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(repository, "applications", applications);
ReflectionTestUtils.setField(repository, "items", items);
ReflectionTestUtils.setField(repository, "cartons", cartons);
ReflectionTestUtils.setField(repository, "profiles", profiles);
ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "discardService", discardService);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(repository, "returnCartons", returnCartons);
application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1");
application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99");
application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00"));
@@ -171,6 +173,33 @@ class PdaCargoDiscardServiceTest {
private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) {
return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk);
}
/** 无箱唛对应的仓位允许实际少弃或未弃,完工后同载荷重放不再结算。 */
@ParameterizedTest @ValueSource(ints = {0, 1, 2})
void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(stockService.cartonMarks(item)).thenReturn(List.of());
when(stockService.actualQuantityEditable(item)).thenReturn(true);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4)));
verifyNoInteractions(photoService);
var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(actual, completed.skus().get(0).actualPackages());
assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual);
verifyNoInteractions(adjustments);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3)));
verify(items, times(1)).updateById(item);
}
@Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() {
looseTask(); when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "BOX-1", "CD-1");
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardDimensionMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实箱内混装SKU按各自件数统计,缺失事实不得用整箱件数替代。 */
class CargoDiscardDimensionRepositoryTest {
private final CargoDiscardDimensionMapper mapper = mock(CargoDiscardDimensionMapper.class);
private final CargoDiscardDimensionRepository repository = new CargoDiscardDimensionRepository();
/** 注入JSON解析和隔离Mapper。 */
CargoDiscardDimensionRepositoryTest() {
ReflectionTestUtils.setField(repository, "dimensions", mapper);
ReflectionTestUtils.setField(repository, "objectMapper", new ObjectMapper());
}
/** 一箱混装A五件和B七件,不能把十二件分别计给两个SKU。 */
@Test void mixedCartonUsesMatchingSkuOnly() {
var line = line(1, "A");
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5},{\"skuCode\":\"B\",\"quantity\":7}]"));
assertEquals(5L, repository.pieces("C", line));
line.setSkuCode("B"); assertEquals(7L, repository.pieces("C", line));
line.setSkuCode("MISSING"); assertNull(repository.pieces("C", line));
}
/** 两箱五件和七件累计十二件,箱数量不足或JSON损坏时保留未知。 */
@Test void everySelectedCartonNeedsKnownPositiveContents() {
var line = line(2, "A");
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]", "[{\"skuCode\":\"A\",\"quantity\":7}]"));
assertEquals(12L, repository.pieces("C", line));
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]"));
assertNull(repository.pieces("C", line));
when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[]", "broken"));
assertNull(repository.pieces("C", line));
}
/** 指定申请箱数和当前SKU,不创建库存。 */
private static CargoDiscardItem line(int boxes, String sku) {
var line = new CargoDiscardItem(); line.setId(1L); line.setDiscardPackages(boxes); line.setSkuCode(sku); return line;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class CargoDiscardQuantityRepositoryTest {
/** 完工前展示申请十箱,完工后费用及列表使用实弃六箱或零箱。 */
@Test void completedSummaryUsesActualWithoutChangingRequestedSnapshot() {
var mapper = mock(CargoDiscardItemMapper.class);
var repository = new CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(repository, "items", mapper);
var application = new CargoDiscardApplication(); application.setCustomerId("C1"); application.setId(1L);
var line = new CargoDiscardItem(); line.setDiscardPackages(10); line.setActualDiscardPackages(6);
when(mapper.listApplication("C1", 1L)).thenReturn(List.of(line));
application.setStatus("APPROVED"); assertEquals(10, repository.boxes(application));
application.setStatus("PDA_COMPLETED"); assertEquals(6, repository.boxes(application));
application.setStatus("COMPLETED"); assertEquals(6, repository.boxes(application));
line.setActualDiscardPackages(0); assertEquals(0, repository.boxes(application));
assertEquals(10, line.getDiscardPackages());
}
}
@@ -40,10 +40,11 @@ class CargoDiscardStatisticsServiceTest {
@Test void unknownPiecesAndLoosePalletNeverBecomeZero() {
when(queries.list("C", query)).thenReturn(List.of(application(1L)));
when(dimensions.items("C", 1L)).thenReturn(List.of(item(null, "A", 2, 0)));
when(dimensions.pieces(eq("C"), any())).thenReturn(null);
when(dimensions.pallets("C", 1L)).thenReturn(List.of(""));
var result = service.statistics("C", query);
assertEquals(2, result.requestedPackages()); assertEquals(0L, result.actualPackages());
assertNull(result.requestedPieces()); assertNull(result.actualPieces()); assertNull(result.palletCount());
assertNull(result.requestedPieces()); assertEquals(0L, result.actualPieces()); assertNull(result.palletCount());
}
/** 空筛选有零票零申请箱,但不能伪造实际作业或商品、托盘事实。 */
@Test void emptySelectionHasNoInventedActualFacts() {
@@ -53,6 +54,29 @@ class CargoDiscardStatisticsServiceTest {
assertNull(result.actualPackages()); assertNull(result.requestedPieces()); assertNull(result.palletCount());
verifyNoInteractions(dimensions);
}
/** 两箱实收五件与七件,实际确认后件数仍由箱级事实汇总。 */
@Test void selectedTransferCartonsProvideRealPieces() {
var line = item(null, "TRANSFER-SKU", 2, 2);
when(queries.list("C", query)).thenReturn(List.of(application(1L)));
when(dimensions.items("C", 1L)).thenReturn(List.of(line));
when(dimensions.pieces("C", line)).thenReturn(12L);
when(dimensions.pallets("C", 1L)).thenReturn(List.of("P"));
var result = service.statistics("C", query);
assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages());
assertEquals(12L, result.requestedPieces()); assertEquals(12L, result.actualPieces());
}
/** 创建一票同客户同仓的隔离单据。 */
@Test void mixedTransferSkuLinesDeduplicatePhysicalBoxesWithoutLosingPieces() {
var a = item(null, "A", 2, 2); var b = item(null, "B", 2, 2);
for (var line : List.of(a, b)) { line.setSourceType("INVENTORY"); line.setInboundCode("ZY-MIX"); line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]"); }
when(queries.list("C", query)).thenReturn(List.of(application(1L)));
when(dimensions.items("C", 1L)).thenReturn(List.of(a, b));
when(dimensions.pieces("C", a)).thenReturn(8L); when(dimensions.pieces("C", b)).thenReturn(18L);
when(dimensions.pallets("C", 1L)).thenReturn(List.of("P", "P"));
var result = service.statistics("C", query);
assertEquals(2, result.skuCount()); assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages());
assertEquals(26L, result.requestedPieces()); assertEquals(26L, result.actualPieces()); assertEquals(1, result.palletCount());
}
/** 创建一票同客户同仓的隔离单据。 */
private static CargoDiscardApplication application(long id) { var a = new CargoDiscardApplication(); a.setId(id); a.setCustomerId("C"); a.setOverseasWarehouseId("W"); return a; }
/** 明确退货件数和实际值,不引入固定箱规。 */
@@ -32,12 +32,14 @@ class CargoDiscardStockServiceTest {
final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "stock", stock);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "stock", stock);
ReflectionTestUtils.setField(repository, "positions", positions);
ReflectionTestUtils.setField(repository, "balances", balances);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "links", links);
ReflectionTestUtils.setField(repository, "items", items);
ReflectionTestUtils.setField(repository, "links", links);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L);
position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L);
@@ -83,6 +85,33 @@ class CargoDiscardStockServiceTest {
var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow();
carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton;
}
/** 同一混装实箱的两个 SKU 保留完整快照,物理仓位只冻结一次,实箱只锁定和保存一次,漏 SKU 时不得产生任何冻结。 */
@Test void mixedTransferCartonRequiresAllSkusAndLocksPhysicalBoxOnce() {
position.setAvailablePackages(1L); balance.setAvailablePackages(1L);
var box = carton(1L, "MIXED-1"); box.setSourceType("TRANSFER");
box.setSkuItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"SECOND\",\"quantity\":7}]");
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(box));
when(stock.changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING")).thenReturn(1);
var a = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(1L));
var b = new CargoDiscardRequests.Item(null, null, "SECOND", 1, 42L, List.of(1L));
assertThrows(ResponseStatusException.class, () -> service.reserveAll("C1", "OP", application, List.of(a), LocalDateTime.now()));
verifyNoInteractions(commands, items, links);
service.reserveAll("C1", "OP", application, List.of(a, b), LocalDateTime.now());
verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING");
verify(links, times(1)).insert(any(CargoDiscardCarton.class));
verify(commands, times(1)).hold(eq("C1"), eq("OP"), any());
var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items, times(2)).insert(saved.capture());
assertTrue(saved.getAllValues().stream().allMatch(i -> service.cartonMarks(i).equals(List.of("MIXED-1"))));
assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), false));
when(items.listApplication("C1", 11L)).thenReturn(saved.getAllValues());
var physical = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(physical.capture());
when(links.listApplication("C1", 11L)).thenReturn(List.of(physical.getValue()));
when(stock.changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED")).thenReturn(1);
for (var line : saved.getAllValues()) { service.discard("C1", "OP", application, line, 1); line.setActualDiscardPackages(1); }
verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED");
verify(commands, times(1)).discardHeld(eq("C1"), eq("OP"), any());
assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), true));
}
@Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
@@ -133,4 +162,57 @@ class CargoDiscardStockServiceTest {
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
/** 即使有同数量箱记录,缺失箱唛时仍应展示相关托盘并允许仓库实点。 */
@Test void incompleteCartonMarksUseActualQuantityWithoutGuessingBoxes() {
position.setAvailablePackages(1L); balance.setAvailablePackages(1L);
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, null)));
var candidate = service.candidates("C1", "WT").get(0);
assertFalse(candidate.cartonSelectionSupported()); assertEquals("P-42", candidate.palletNo());
service.reserve("C1", "OP", application, request(1), LocalDateTime.now());
var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(saved.capture());
assertTrue(service.actualQuantityEditable(saved.getValue()));
assertTrue(service.cartonMarks(saved.getValue()).isEmpty());
verifyNoInteractions(links);
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
/** 预留十箱、实弃六箱,扣减与释放分别使用稳定幂等键。 */
@Test void unboundPalletDiscardsActualAndReleasesUnfinishedReservation() {
var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY");
line.setInventoryPositionId(42L); line.setDiscardPackages(10);
assertTrue(service.actualQuantityEditable(line));
service.discard("C1", "OP", application, line, 6);
var deduction = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands).discardHeld(eq("C1"), eq("OP"), deduction.capture());
verify(commands).release(eq("C1"), eq("OP"), release.capture());
assertEquals(6, deduction.getValue().packages()); assertEquals(4, release.getValue().packages());
assertEquals(deduction.getValue().sourceEventId(), release.getValue().sourceEventId());
assertNotEquals(deduction.getValue().idempotencyKey(), release.getValue().idempotencyKey());
verifyNoInteractions(stock, links);
}
/** 实际未废弃时只释放全部预留,不产生零数量扣减。 */
@Test void unboundPalletZeroDisposalReleasesAllHeldStock() {
var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY");
line.setInventoryPositionId(42L); line.setDiscardPackages(10);
service.discard("C1", "OP", application, line, 0);
verify(commands, never()).discardHeld(anyString(), anyString(), any());
var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class);
verify(commands).release(eq("C1"), eq("OP"), release.capture());
assertEquals(10, release.getValue().packages());
}
/** 已选具体箱子不能通过实际数量绕过核对,超量和负数同样不得写库存。 */
@Test void exactBoxesAndInvalidActualsCannotUseQuantityOnlyCompletion() {
var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY");
line.setInventoryPositionId(42L); line.setDiscardPackages(2);
assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, -1));
assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 3));
line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]");
assertFalse(service.actualQuantityEditable(line));
assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 1));
verifyNoInteractions(commands, stock, links);
}
}
@@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest {
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
DriverEvidenceFixture() throws Exception {
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class));
var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
}
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.exception.BizException;
@@ -326,7 +328,7 @@ class DispatchItemSourcePersistenceTest {
capture();
var allocated = allocate(first, 101, 3, 10, null, 6);
assertEquals(2, allocated.size());
assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum());
assertEquals(3, allocated.stream().mapToLong(DispatchSourceAllocation::packages).sum());
assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum());
assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count());
assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6));
@@ -368,7 +370,7 @@ class DispatchItemSourcePersistenceTest {
}
/** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */
private List<DispatchItemSourceService.Allocation> allocate(DispatchItem item, long position, long packages,
private List<DispatchSourceAllocation> allocate(DispatchItem item, long position, long packages,
long quantity, String mark, long hold) {
long balance = position == 102 ? 602 : 601;
return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark,
@@ -376,7 +378,7 @@ class DispatchItemSourcePersistenceTest {
}
/** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */
private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) {
private void pick(long id, String customer, long fulfillment, DispatchSourceAllocation allocation) {
jdbc.update("""
INSERT INTO ll_dispatch_pda_pick_order
(id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at)
@@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.*;
class FleetPickupMapperTest {
JdbcTemplate db;FleetPickupMapper mapper;
/** 每个用例建立独立数据库,通过真实 Mapper 验证取货只读来源投影。 */
@BeforeEach void setup() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)");
@@ -23,6 +24,26 @@ class FleetPickupMapperTest {
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class);
}
/** 释放隔离库,避免完整回归累积 H2 内存。 */
@AfterEach void close() { if(db!=null)db.execute("SHUTDOWN"); }
/** 显式列投影保留车队全部资格和展示字段,不依赖转运实体映射。 */
@Test void sourceProjectionPreservesPickupFactsAndCustomerScope() {
var source=mapper.lockSource("C1","WT-1");
assertThat(source.getId()).isEqualTo(1L);
assertThat(source.getCustomerId()).isEqualTo("C1");
assertThat(source.getTransferNo()).isEqualTo("WT-1");
assertThat(source.getInboundCode()).isEqualTo("WT-1");
assertThat(source.getLastlegOrderNo()).isEqualTo("LL-1");
assertThat(source.getOperatingWarehouseId()).isEqualTo("100");
assertThat(source.getDeliveryAddress()).isEqualTo("Ontario Test Address");
assertThat(source.getPickupContactName()).isEqualTo("Contact");
assertThat(source.getPickupContactPhone()).isEqualTo("0000000000");
assertThat(source.getPackageCount()).isEqualTo(5);
assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(source.getDeliveryMethod()).isEqualTo("WAREHOUSE_PICKUP");
assertThat(mapper.lockSource("C2","WT-1")).isNull();
assertThat(mapper.source("C1","WT-1").getId()).isEqualTo(source.getId());
}
@Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() {
assertThat(mapper.countCandidates(null)).isEqualTo(2);
assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1");
@@ -4,7 +4,6 @@ import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.fleet.reporting.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.springframework.web.server.ResponseStatusException;
@@ -16,7 +15,7 @@ import static org.mockito.ArgumentMatchers.*;
class FleetPickupServiceTest {
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task;
TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task;
final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup() {
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
@@ -30,15 +29,18 @@ class FleetPickupServiceTest {
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
@Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() {
when(repository.pdaSource("100","WT-1")).thenReturn(source);
assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓");
task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);
assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class);
/** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */
@Test void pickupMustArriveBeforePda() {
assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓");
task.setStatus("ARRIVED");
assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).doesNotThrowAnyException();
assertThatThrownBy(()->service.requireArrived(2L,"WAREHOUSE_PICKUP","WT-2","WT-2")).hasMessageContaining("先完成车辆取货送仓");
}
/** 客户自送和已有库存转运沿本域原测量资格,不要求不存在的取货任务。 */
@Test void selfDeliveryKeepsOriginalMeasurementPath() {
source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source);
assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong());
assertThatCode(()->service.requireArrived(1L,"CUSTOMER_DELIVERY","WT-1","WT-1")).doesNotThrowAnyException();
assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","ORIGINAL-INBOUND")).doesNotThrowAnyException();
verify(repository,never()).task(anyLong());
}
@Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() {
var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1");
@@ -98,8 +100,8 @@ class FleetPickupServiceTest {
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
/** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */
static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) {
var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number);
static FleetPickupSource source(long id,String customer,String number,String warehouse) {
var row=new FleetPickupSource();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number);
row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5);
row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row;
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.inventory.query;
import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow;
import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository;
import com.baiyue.lastleg.inventory.query.service.InventoryPieceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class InventoryPieceServiceTest {
/** 混箱全部SKU与数字字符串正确投影,多组共享物理托盘只累计一次。 */
@Test void projectsAllSkusWithoutMultiplyingCartonsOrPallets() {
var repository=mock(InventoryPieceRepository.class);
when(repository.rows("A",null)).thenReturn(List.of(
row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":\"7\"}]"),
row(1,10,100,2,102,2L,"[{\"skuCode\":\"A\",\"quantity\":2}]"),
row(2,11,100,1,103,4L,"[{\"skuCode\":\"C\",\"quantity\":4}]")));
var result=service(repository).project("A",null);
assertEquals(16L,result.availablePieceCount()); assertEquals(3,result.availableCartonCount()); assertEquals(1,result.availablePalletCount());
assertEquals(2,result.groups().size()); assertEquals(2,result.groups().get(0).skuContents().size());
assertEquals(7,result.groups().get(0).skuContents().get(1).quantity());
}
/** 箱映射不全、JSON缺失或件数冲突均保留未知,不拿已知部分冒充总数。 */
@Test void incompleteAndInconsistentPositionsRemainUnknown() {
for (var rows:List.of(
List.of(row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":10}]")),
List.of(row(1,10,100,1,101,10L,null)),
List.of(row(1,10,100,1,101,10L,"[{\"skuCode\":\"A\",\"quantity\":9}]")))) {
var repository=mock(InventoryPieceRepository.class); when(repository.rows("A",null)).thenReturn(rows);
var result=service(repository).project("A",null);
assertNull(result.availablePieceCount()); assertEquals(0,result.knownAvailablePieceCount());
assertEquals(rows.get(0).availablePackages(),result.unknownCartonCount()); assertTrue(result.groups().get(0).skuContents().isEmpty());
}
}
/** 手工注入只读仓储及JSON解析器,测试不依赖运行服务。 */
private InventoryPieceService service(InventoryPieceRepository repository) {
var service=new InventoryPieceService(); ReflectionTestUtils.setField(service,"repository",repository);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper()); return service;
}
/** 保留每个仓位所属记账分组及真实箱身份。 */
private PieceRow row(long position,long balance,long pallet,long boxes,long carton,Long quantity,String json) {
return new PieceRow(position,balance,"A","W","DEST",balance==10?"A":"C",pallet,boxes,carton,"BOX-"+carton,quantity,json);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class PalletMutationOrderMapperTest {
/** 执行真实Mapper,证明入仓ID不同于尾程ID且同源分片跨客户不会串锁。 */
@Test void resolvesSnapshotIdsToRealOrdersAndKeepsMissingSourcesFailClosed() {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(ds);
try {
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,relabel_no VARCHAR,lastleg_order_id BIGINT)");
db.execute("CREATE TABLE ll_relabel_item(customer_id VARCHAR,application_id BIGINT,source_inbound_order_id BIGINT,return_carton_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,overseas_warehouse_id VARCHAR,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT)");
db.execute("CREATE TABLE ll_return_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,plan_id BIGINT,customer_id VARCHAR,line_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT)");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR,source_return_no VARCHAR,overseas_warehouse_id VARCHAR,total_quantity BIGINT,pallet_code VARCHAR)");
db.execute("CREATE TABLE ll_return_order(customer_id VARCHAR,return_no VARCHAR,lastleg_order_id BIGINT)");
db.update("INSERT INTO ll_relabel_application VALUES(1,'C','RL',900)");
db.update("INSERT INTO ll_relabel_item VALUES('C',1,100,NULL)");
db.update("INSERT INTO ll_inbound_order VALUES(100,'C','WAREHOUSE_TRANSFER:ZY'),(101,'C','MISSING')");
db.update("INSERT INTO ll_order VALUES(200,'C','WAREHOUSE_TRANSFER:ZY'),(201,'OTHER','WAREHOUSE_TRANSFER:ZY')");
db.update("INSERT INTO ll_inventory_balance VALUES(10,'C',100),(11,'C',101)");
db.update("INSERT INTO ll_inventory_position VALUES(20,'C',10,'WH',500),(21,'C',11,'WH',501)");
var configuration=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
configuration.addMapper(PdaPalletOperationMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(configuration).openSession()){
var mapper=session.getMapper(PdaPalletOperationMapper.class);
var rows=mapper.mutationOrders("WH",List.of(500L),List.of("P500"),List.of("RL"));
assertThat(rows).extracting(PdaPalletOperationMapper.MutationOrder::orderId).containsExactlyInAnyOrder(900L,200L);
assertThat(rows).allSatisfy(r->assertThat(r.customerId()).isEqualTo("C"));
assertThat(mapper.mutationOrders("WH",List.of(501L),List.of("P501"),List.of())).singleElement().satisfies(r->{assertThat(r.customerId()).isEqualTo("C");assertThat(r.orderId()).isNull();});
}
} finally {
// Mapper会话先关闭,再销毁本用例独有的H2数据库,不把内存库留给全量回归。
db.execute("SHUTDOWN");
}
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.palletops.service;
import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class PalletOperationRecoveryServiceTest {
private final PalletOperationRecoveryRepository repository=mock(PalletOperationRecoveryRepository.class);
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
private final PalletOperationRecoveryService service=new PalletOperationRecoveryService(repository,json);
private PdaPalletOperation row(){var r=new PdaPalletOperation();r.setId(9L);r.setOverseasWarehouseId(88L);r.setOperationType("RELABEL_PALLET_DISASSEMBLE");r.setStatus("PROCESSING");r.setUpdatedAt(LocalDateTime.now().minusHours(1));r.setRequestHash("original-hash");return r;}
private PalletOperationRecoveryService.Close request(PdaPalletOperation r){return new PalletOperationRecoveryService.Close(88L,r.getUpdatedAt(),"recovery-1","核实无远端意图且无业务提交");}
@Test void closesOnlyNoEffectAndReplaysImmutableAudit() throws Exception {
var row=row();row.setLastError("original failure");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true);
var result=service.close(9L,"admin",request(row));assertEquals("RECOVERED_NO_EFFECT",result.status());assertEquals("original failure",result.previousError());assertFalse(result.replayed());
row.setStatus(result.status());row.setResultJson(json.writeValueAsString(result));
var replay=service.close(9L,"admin",request(row));assertTrue(replay.replayed());assertEquals(result.closedAt(),replay.closedAt());verify(repository,times(1)).close(eq(9L),anyString(),any());assertEquals("original-hash",row.getRequestHash());
assertThrows(ResponseStatusException.class,()->service.close(9L,"different-admin",request(row)));
}
@Test void unknownOrConfirmedRemoteAttemptsCannotBeClosed(){for(String journal:new String[]{"[{\"operationKey\":\"x\",\"receipt\":null}]","[{\"receipt\":{\"operationStatus\":\"APPLIED\"}}]","{}","corrupt"}){var row=row();row.setRemoteTransitionsJson(journal);when(repository.lock(9L)).thenReturn(row);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}verify(repository,never()).close(anyLong(),anyString(),any());}
@Test void activeChangedCompletedAndWrongWarehouseReject(){var row=row();when(repository.lock(9L)).thenReturn(row);var old=request(row);row.setUpdatedAt(LocalDateTime.now());assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",old));assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("COMPLETED");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("PROCESSING");row.setOverseasWarehouseId(99L);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));verify(repository,never()).close(anyLong(),anyString(),any());}
@Test void conditionalWriteLossIsNotSuccess(){var row=row();when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(false);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}
/** 已补偿失败也须逐条确认无意图,审计保留真实前态。 */
@Test void failedCompensatedRequiresTheSameNoRemoteProof(){var row=row();row.setStatus("FAILED_COMPENSATED");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true);assertEquals("FAILED_COMPENSATED",service.close(9L,"admin",request(row)).previousStatus());row.setRemoteTransitionsJson("[{\"receipt\":null}]");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}
}
@@ -19,7 +19,9 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.mapper.PalletOperationRecoveryMapper;
import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository;
import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
@@ -36,6 +38,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
@@ -65,6 +68,13 @@ import static org.mockito.Mockito.*;
/** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */
class PdaPalletDispatchFreezePersistenceTest {
private final List<Fixture> fixtures = new ArrayList<>();
/** 每个用例结束都销毁其独立H2库,避免DB_CLOSE_DELAY=-1在全套测试中累计保留。 */
@AfterEach void closeFixtures() {
for (var fixture : fixtures) fixture.db.execute("SHUTDOWN");
fixtures.clear();
}
private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0);
private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L);
@@ -343,7 +353,7 @@ class PdaPalletDispatchFreezePersistenceTest {
}
/** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */
static class Fixture {
class Fixture {
final JdbcTemplate db;
final PdaPalletOperationService service = new PdaPalletOperationService();
final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class);
@@ -366,9 +376,12 @@ class PdaPalletDispatchFreezePersistenceTest {
}
};
db = new JdbcTemplate(ds);
// SqlSessionTemplate每次调用自行释放会话;用例最后显式关闭仅本夹具拥有的数据库。
fixtures.add(this);
baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type);
createColumns("ll_order",List.of("id","customer_id","detained"));
createColumns("ll_order",List.of("id","customer_id","detained","source_inbound_id"));
createColumns("ll_inbound_order",List.of("id","customer_id","source_inbound_id"));
createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id"));
createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id"));
createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id"));
@@ -381,6 +394,7 @@ class PdaPalletDispatchFreezePersistenceTest {
createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at"));
createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status"));
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(PalletOperationRecoveryMapper.class);
for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration);
var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
@@ -401,6 +415,7 @@ class PdaPalletDispatchFreezePersistenceTest {
operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class));
var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory);
ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced));
ReflectionTestUtils.setField(service,"recoveryRepository",new PalletOperationRecoveryRepository(sessions.getMapper(PalletOperationRecoveryMapper.class)));
ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical);
ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class));
ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class));
@@ -480,8 +495,12 @@ class PdaPalletDispatchFreezePersistenceTest {
/** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */
void position(long id,String customer,long balance,long pallet,long order,long held) {
db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer);
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order);
// 入仓快照和尾程订单是不同主键,经同客户的来源身份解析,禁止借相同ID掩盖映射错误。
String source = "TEST-SOURCE-"+order;
long inbound = order+100000;
db.update("INSERT INTO ll_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",order,customer,source);
db.update("INSERT INTO ll_inbound_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",inbound,customer,source);
db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,inbound);
String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER";
db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0);
}
@@ -62,6 +62,7 @@ class PdaPalletOperationServiceTest {
}
@Mock private PdaPalletOperationMapper mapper;
@Mock private PdaPalletOperationRepository mutationRepository;
@Mock private com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository recoveryRepository;
@Mock private PhysicalPalletSerialPort physicalPalletSerial;
@Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands;
@Mock private InventoryPositionMapper positions;
@@ -82,6 +83,8 @@ class PdaPalletOperationServiceTest {
ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class));
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository);
ReflectionTestUtils.setField(service, "recoveryRepository", recoveryRepository);
lenient().when(recoveryRepository.journal(anyLong(),anyString(),any())).thenReturn(true);
ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial);
// 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。
lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> {
@@ -577,6 +580,8 @@ class PdaPalletOperationServiceTest {
}
@Test void unboundReturnRelabelSourceDoesNotUnboxNullPalletIdentity() {
ReflectionTestUtils.setField(service, "relabelWorkTasks",
mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class));
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
@@ -76,6 +76,9 @@ class PdaRelabelServiceTest {
@BeforeEach
void setUp() {
service = new PdaRelabelService();
var quantityRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository.class);
lenient().when(quantityRepository.sourceSnapshot(any())).thenReturn(java.util.Map.of("itemBindings", List.of()));
ReflectionTestUtils.setField(service, "quantityRepository", quantityRepository);
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
ReflectionTestUtils.setField(service, "applications", applications);
@@ -12,6 +12,55 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class RelabelQuantityServiceTest {
/** 构造不可变历史供多轮和多来源回归使用。 */
private com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory history(String action, String json) {
var event = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
event.setAction(action); event.setDetailJson(json); return event;
}
@Test void exactBindingsKeepOriginalAndReplaceTargetsAcrossRebindAndRework() {
var a = item("CARTON_AND_SKU", "A", 1); a.setId(1L);
var b = item("CARTON_AND_SKU", "B", 1); b.setId(2L);
var first = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P1\",\"targetPalletCode\":\"T1\"},{\"itemId\":\"2\",\"sourceKnown\":true,\"sourcePalletCode\":\"P2\",\"targetPalletCode\":\"T1\"}]}");
var second = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"T1\",\"targetPalletCode\":\"T2\"}]}");
var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, second), new ObjectMapper());
assertEquals(java.util.Set.of("P1", "P2"), result.sources());
assertEquals(java.util.Set.of("T1", "T2"), result.targets());
result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, history("REWORK_ARCHIVE", "{}"), second), new ObjectMapper());
assertEquals(java.util.Set.of("P1", "P2"), result.sources()); assertNull(result.targets());
}
@Test void ambiguousLegacySourcesAndCurrentPositionsCannotInventOriginalPallets() {
var a = item("SKU_LABEL", "A", 1); a.setId(1L); a.setSourceInventoryPositionId(999L);
var result = RelabelPalletDimensions.measure(List.of(a), List.of(history("PALLET_BIND", "{\"cartonMarks\":[\"A\"],\"sourcePallets\":[\"P1\",\"P2\"],\"targetPallets\":[\"T1\"]}")), new ObjectMapper());
assertNull(result.sources()); assertEquals(java.util.Set.of("T1"), result.targets());
}
@Test void bulkSourceTypesUseExactIdsAndNeverTreatPalletOrParcelAsBoxes() {
var a = item("RETURN_BULK_SKU_LABEL", "M", 2); a.setId(1L);
var b = item("RETURN_BULK_SKU_LABEL", "M", 3); b.setId(2L);
var box = history("SOURCE_SNAPSHOT", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"},{\"itemId\":\"2\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"}]}");
var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper());
assertEquals(1L, result.cartons()); assertEquals(java.util.Map.of("BOX",1L), result.sourceUnits());
for (var type : List.of("PALLET", "PARCEL")) {
box.setDetailJson(box.getDetailJson().replace("BOX", type));
result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper());
assertNull(result.cartons()); assertEquals(1L, result.sourceUnits().get(type));
box.setDetailJson(box.getDetailJson().replace(type, "BOX"));
}
}
@Test void allFilterPalletTotalsDeduplicateSameWarehouseAcrossOrders() {
var service = service(); var repo = mock(RelabelQuantityRepository.class);
ReflectionTestUtils.setField(service,"repository",repo);
var a = new RelabelApplication(); a.setRelabelNo("A"); a.setOverseasWarehouseId("W");
var b = new RelabelApplication(); b.setRelabelNo("B"); b.setOverseasWarehouseId("W");
var item = item("SKU_LABEL", "A", 1); item.setId(1L);
var event = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\",\"targetPalletCode\":\"T\"}]}");
var query = new RelabelRequests.Query(2,1,null,null,null,null,null,null,null);
when(repo.filtered(null,query)).thenReturn(List.of(a,b));
when(repo.items(a)).thenReturn(List.of(item)); when(repo.items(b)).thenReturn(List.of(item));
when(repo.history(a)).thenReturn(List.of(event)); when(repo.history(b)).thenReturn(List.of(event));
var result = service.totals(null,query);
assertEquals(1L,result.knownSourcePalletCount()); assertEquals(1L,result.knownTargetPalletCount());
assertEquals(0L,result.unknownSourcePalletOrders()); assertEquals(2,result.orderCount());
}
/** 以真实对象测试件数、箱数的不同业务口径。 */
private RelabelQuantityService service() {
var service = new RelabelQuantityService();
@@ -54,7 +54,9 @@ class ReturnInventoryMovementSqlTest {
var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method)
.getBoundSql(params);
String sql = boundSql.getSql();
assertEquals(8, sql.split("UNION ALL", -1).length);
// 原有八种库存流水加上正式废弃扣减分支,完整保留投影和客户过滤合同。
assertEquals(9, sql.split("UNION ALL", -1).length);
assertTrue(sql.contains("'DISCARD'"));
assertTrue(sql.contains("ll_return_carton_stock_receipt"));
assertTrue(!sql.contains("CONVERT("));
assertTrue(sql.contains("m.customerId=?"));
@@ -77,6 +77,7 @@ class LastlegOrderServiceTest {
@Mock ReturnOrderReferenceResolver returnOrderReferences;
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
LastlegOrderService service;
@Mock com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
@Test
void exposesWarehouseTransferNumberAsTailOrderNumber() {
@@ -104,7 +105,7 @@ class LastlegOrderServiceTest {
LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod(
service, "summary", order, List.of(), null, "UNSCHEDULED",
new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null);
new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null, null);
assertNotNull(summary);
assertEquals("BOOKING-1", summary.sourceContainerBookingId());
@@ -117,6 +118,7 @@ class LastlegOrderServiceTest {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class);
service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.transfer;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper;
import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TransferQuantityMapperTest {
/** 使用真实MyBatis注册解析动态XML,避免仅javac放过未转义比较符。 */
@Test void registersBothDynamicQuantityMappers() {
var configuration = new MybatisConfiguration();
assertDoesNotThrow(() -> configuration.addMapper(TransferQuantityMapper.class));
assertDoesNotThrow(() -> configuration.addMapper(InventoryPieceMapper.class));
assertTrue(configuration.hasStatement(TransferQuantityMapper.class.getName() + ".rows"));
assertTrue(configuration.hasStatement(InventoryPieceMapper.class.getName() + ".rows"));
}
/** 真正执行SQL核对未知件数、出库后历史实收、同托重叠及客户隔离。 */
@Test void executesQuantityFactsWithoutLosingUnknownsOrShippedHistory() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:transfer-quantity-" + System.nanoTime() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR)");
sql.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,package_count BIGINT)");
sql.execute("CREATE TABLE ll_warehouse_transfer_item(customer_id VARCHAR,transfer_order_id BIGINT,declared_quantity BIGINT)");
sql.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_pallet_id BIGINT,counted_at TIMESTAMP,status VARCHAR,counted_quantity BIGINT)");
sql.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)");
sql.execute("INSERT INTO ll_order VALUES(1,'A'),(2,'A'),(3,'B')");
sql.execute("INSERT INTO ll_warehouse_transfer_order VALUES(11,'A',1,3),(12,'A',2,1),(13,'B',3,1)");
sql.execute("INSERT INTO ll_warehouse_transfer_item VALUES('A',11,10),('A',11,NULL),('A',12,4),('B',13,999)");
sql.execute("INSERT INTO ll_warehouse_transfer_pallet VALUES(21,'A',100),(22,'B',100)");
sql.execute("INSERT INTO ll_warehouse_transfer_carton VALUES('A',11,21,CURRENT_TIMESTAMP,'COUNTED',5),('A',11,21,CURRENT_TIMESTAMP,'SHIPPED',7),('A',11,NULL,NULL,'NOT_RECEIVED',NULL),('A',12,NULL,CURRENT_TIMESTAMP,'DISCARDED',NULL),('B',13,22,CURRENT_TIMESTAMP,'COUNTED',999)");
}
var configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
configuration.addMapper(TransferQuantityMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession()) {
var mapper = session.getMapper(TransferQuantityMapper.class);
var filter = new QueryWrapper<LastlegOrder>().eq("customer_id", "A").orderByAsc("id");
var rows = mapper.rows(filter);
assertEquals(2, rows.size());
var first = rows.get(0);
assertNull(first.declaredPieceCount());
assertEquals(12L, first.receivedPieceCount());
assertEquals(2L, first.receivedCartonCount());
assertEquals(1L, first.receivedPalletCount());
assertEquals(1L, first.remainingPalletCount());
assertEquals(1L, first.shippedPalletCount());
assertNull(rows.get(1).receivedPieceCount());
var pallets = mapper.pallets(filter);
assertEquals(2, pallets.size());
assertTrue(pallets.stream().allMatch(pallet -> pallet.customerId().equals("A")));
assertTrue(mapper.rows(new QueryWrapper<LastlegOrder>().eq("customer_id", "NONE")).isEmpty());
}
}
}
@@ -18,7 +18,7 @@ class MerchantWarehouseTransferControllerTest {
private final com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService = mock(com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService.class);
private final MerchantWarehouseTransferController controller = controller();
private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantPageUsesOnlyAuthenticatedTenant() {
@@ -26,6 +26,13 @@ class MerchantWarehouseTransferControllerTest {
verify(service).page("100", query);
}
/** 汇总只使用已认证客户,平台身份不可借客户入口读取数量。 */
@Test void quantityTotalsUsesOnlyAuthenticatedCustomer() {
controller.quantities(user(100L,"MERCHANT"),query);
verify(service).quantityTotals("100",query);
assertThrows(ResponseStatusException.class,() -> controller.quantities(user(0L,"PLATFORM"),query));
}
@Test
void merchantDetailUsesOnlyAuthenticatedTenant() {
controller.detail(user(100L, "MERCHANT"), "TR001");
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.transfer.dto.TransferQuantityView;
import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.PalletFact;
import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TransferQuantityServiceTest {
/** 库存转运签收使用目的仓实收凭据,同时保留原仓已出库托盘。 */
@Test void destinationReceiptUsesExactOriginalPiecesAndPhysicalTargetPallets() {
var repository=mock(TransferQuantityRepository.class);
var service=new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository);
var filter=new QueryWrapper<LastlegOrder>().eq("id",3);
when(repository.rows(filter)).thenReturn(List.of(new TransferQuantityView(3L,14L,null,2,0,0,0,0,0,0)));
when(repository.movements(filter)).thenReturn(List.of(
new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,100L,"RECEIVED",1L,200L,10L),
new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,101L,"RECEIVED",2L,201L,4L)));
var result=service.totals(filter);
assertEquals(14L,result.receivedPieceCount()); assertEquals(2,result.receivedCartonCount());
assertEquals(2,result.receivedPalletCount()); assertEquals(2,result.shippedPalletCount());
assertEquals(2,result.shippedCartonCount());
}
/** 完整筛选总量保留未知,托盘按客户去重且剩余与出库集合允许重叠。 */
@Test void keepsUnknownsAndDeduplicatesOverlappingPalletsAcrossCustomers() {
var repository = mock(TransferQuantityRepository.class);
var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository);
var filter = new QueryWrapper<LastlegOrder>().eq("order_type","WAREHOUSE_TRANSFER");
when(repository.rows(filter)).thenReturn(List.of(
new TransferQuantityView(1L,null,12L,3,2,1,1,1,1,1),
new TransferQuantityView(2L,4L,null,1,1,1,1,0,1,0)));
when(repository.pallets(filter)).thenReturn(List.of(new PalletFact("A",100L,"COUNTED"),
new PalletFact("A",100L,"SHIPPED"),new PalletFact("A",100L,"COUNTED"),new PalletFact("B",100L,"COUNTED")));
var totals = service.totals(filter);
assertEquals(2,totals.orderCount()); assertNull(totals.declaredPieceCount()); assertEquals(4,totals.knownDeclaredPieceCount());
assertEquals(1,totals.unknownDeclaredOrderCount()); assertNull(totals.receivedPieceCount());
assertEquals(12,totals.knownReceivedPieceCount()); assertEquals(1,totals.unknownReceivedOrderCount());
assertEquals(2,totals.receivedPalletCount()); assertEquals(2,totals.remainingPalletCount()); assertEquals(1,totals.shippedPalletCount());
verify(repository).rows(filter); verify(repository).pallets(filter);
}
/** 空筛选返回准确零值,非转运订单无需额外数量读取。 */
@Test void emptyFilterIsZeroAndNonTransferPageSkipsRepository() {
var repository = mock(TransferQuantityRepository.class);
var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository);
var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL");
assertTrue(service.forOrders(List.of(order)).isEmpty()); verifyNoInteractions(repository);
var totals = service.totals(new QueryWrapper<LastlegOrder>().apply("1=0"));
assertEquals(0L,totals.declaredPieceCount()); assertEquals(0L,totals.receivedPieceCount()); assertEquals(0,totals.receivedPalletCount());
}
}
@@ -27,7 +27,6 @@ class WarehouseTransferCartonCountTest {
service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items);
@@ -143,7 +143,7 @@ class WarehouseTransferServiceTest {
when(relabelApplications.selectList(any())).thenReturn(List.of(relabel));
var result = service.page("TENANT-A", new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null));
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null));
assertEquals("RL-20260923-000001", result.records().get(0).relabelNo());
assertEquals("COMPLETED", result.records().get(0).relabelStatus());
@@ -162,7 +162,7 @@ class WarehouseTransferServiceTest {
when(transfers.selectPage(any(), any())).thenReturn(resultPage);
var result = service.page("TENANT-A", new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null));
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null));
assertEquals(1, result.records().size());
assertNull(result.records().get(0).relabelStatus());
@@ -233,7 +233,6 @@ class WarehouseTransferServiceTest {
});
ReflectionTestUtils.setField(service, "transfers", transfers);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0)));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
@@ -521,8 +520,6 @@ class WarehouseTransferServiceTest {
transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true);
transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);