This commit is contained in:
2026-10-20 11:30:34 +08:00
parent c13175bf77
commit 16ac817524
92 changed files with 1916 additions and 190 deletions
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class OutboundQuantityRepositoryTest {
/** 箱唛事实优先于历史件数,同箱多 SKU、多箱一行和无箱唛库存均须正确计数。 */
@Test void countsUniqueMarksWithoutDependingOnHistoricPieceMetadata() {
var mapper = mock(OutboundPalletItemMapper.class);
var repository = new OutboundQuantityRepository();
ReflectionTestUtils.setField(repository, "pallets", mapper);
var outbound = new OutboundOrder();
outbound.setId(1L); outbound.setCustomerId("C-1"); outbound.setOutboundType("DISCARD_OUTBOUND");
outbound.setPackageCount(99);
var a = new OutboundPalletItem(); a.setCartonMarks("BOX-1, BOX-2"); a.setPackageCount(5);
var b = new OutboundPalletItem(); b.setCartonMarks("BOX-2,BOX-1"); b.setPackageCount(8);
var unmarked = new OutboundPalletItem(); unmarked.setPackageCount(3);
when(mapper.findByOutbound("C-1", 1L)).thenReturn(List.of(a, b, unmarked));
assertEquals(5, repository.boxes(outbound));
assertEquals(99, outbound.getPackageCount());
outbound.setOutboundType("RETURN_OUTBOUND");
assertEquals(99, repository.boxes(outbound));
verify(mapper, times(1)).findByOutbound("C-1", 1L);
verifyNoMoreInteractions(mapper);
}
}
@@ -133,6 +133,9 @@ class DeliveryTransferServiceTest {
@BeforeEach
void setUp() {
service = new DeliveryTransferService();
var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
ReflectionTestUtils.setField(quantities, "pallets", outboundPallets);
ReflectionTestUtils.setField(service, "outboundQuantities", quantities);
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
ReflectionTestUtils.setField(service, "execution", execution);
ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
@@ -491,6 +494,27 @@ class DeliveryTransferServiceTest {
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
}
/** 历史废弃单一箱内的多种 SKU 件数不能累加成出库箱数。 */
@Test
void discardListAndDetailCountPhysicalCartonsInsteadOfHistoricPieces() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "discard-301");
outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5);
var a = pallet(501L, "P-1", null, null, "RT-1", 2);
var b = pallet(502L, "P-1", null, null, "RT-1", 3);
a.setReturnSkuQuantity(2L); b.setReturnSkuQuantity(3L);
a.setCartonMarks("BOX-1"); b.setCartonMarks("BOX-1");
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(a, b));
when(outboundOrders.selectPage(any(Page.class), any())).thenReturn(new Page<OutboundOrder>(1, 10, 1).setRecords(List.of(outbound)));
assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount());
var query = new DeliveryRequests.Query(1, 10, "OUT-301", null, null, null, null, null, null, null, null, null, null, "DISCARD_OUTBOUND");
assertEquals(1, service.outboundPage(null, query).records().get(0).packageCount());
assertEquals(5, outbound.getPackageCount());
assertEquals(2L, a.getReturnSkuQuantity()); assertEquals(3L, b.getReturnSkuQuantity());
verify(outboundOrders, never()).updateById(any(OutboundOrder.class));
}
@Test
void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
@@ -57,6 +57,11 @@ class ReturnOutboundClosureTest {
ReturnInventoryCarton carton;
@BeforeEach void setup() {
// 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。
ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons,
mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),
mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class), mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class)));
ReflectionTestUtils.setField(service, "outboundOrders", outbounds);
ReflectionTestUtils.setField(service, "outboundPallets", items);
ReflectionTestUtils.setField(service, "pdaTasks", tasks);
@@ -54,6 +54,8 @@ class ReturnOutboundPdaPersistenceTest {
+ (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")");
}
jdbc.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(2000),overseas_warehouse_id VARCHAR(2000),discard_no VARCHAR(2000))");
jdbc.execute("CREATE TABLE ll_cargo_discard_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(2000),return_carton_id BIGINT,sku_code VARCHAR(2000),product_name VARCHAR(2000),pda_status VARCHAR(2000),available_packages_snapshot BIGINT,actual_discard_packages BIGINT,pda_operator_id VARCHAR(2000),pda_completed_at TIMESTAMP)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class,
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.model.CargoDiscardReturnMark;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** 废弃标签与签收实物标签一致,箱及托盘来源仍使用独立箱唛。 */
class CargoDiscardReturnMarkTest {
/** 兼容历史 RP 库存记录,不通过字符串替换猜测 PK 编码。 */
@Test void historicalParcelUsesExactReceiptUnitCode() {
var carton = new ReturnInventoryCarton(); carton.setSourceUnitType("PARCEL");
carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5");
assertEquals("PK-G1OTH7E5RRB5", CargoDiscardReturnMark.of(carton));
carton.setSourceUnitType("BOX"); assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton));
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode(" ");
assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton));
assertNull(CargoDiscardReturnMark.of(null));
}
}
@@ -20,8 +20,7 @@ class ReturnDiscardMovementMapperTest {
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_cargo_discard_application(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),discard_no VARCHAR(30))");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
db.execute("ALTER TABLE ll_return_inventory_carton ADD carton_mark VARCHAR(30), ADD source_batch_no VARCHAR(30), ADD created_at TIMESTAMP");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),carton_mark VARCHAR(30),source_batch_no VARCHAR(30),created_at TIMESTAMP)");
db.execute("CREATE TABLE ll_return_inventory_carton_item(carton_id BIGINT,sku_code VARCHAR(30),product_name_zh VARCHAR(30),quantity BIGINT)");
db.execute("CREATE TABLE ll_return_receipt(id BIGINT,return_order_id BIGINT,receipt_no VARCHAR(30),signed_at TIMESTAMP)");
db.execute("CREATE TABLE ll_return_order(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
@@ -193,18 +193,19 @@ class PdaCargoDiscardServiceTest {
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RP-OLD");
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL");
when(returnCartons.selectById(31L)).thenReturn(carton);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertEquals("CARTON", task.scanMode());
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
assertEquals(List.of("PK-REAL"), task.cartonMarks());
assertEquals(5, task.skus().get(0).plannedPackages());
assertEquals("件", task.skus().get(0).quantityUnit());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
verifyNoInteractions(adjustments);
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
List.of(101L), List.of("RETURN-BOX-1"), false);
List.of(101L), List.of("PK-REAL"), false);
service.confirm(identity, "CD-1", "PALLET-1", confirm);
assertEquals(1, application.getTotalPackages());
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
@@ -94,9 +94,10 @@ class CargoDiscardOutboundServiceTest {
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31");
carton.setOverseasWarehouseId("99"); carton.setCartonMark("RP-OLD");
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks());
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("PK-REAL", snapshots.get(0).getCartonMarks());
assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
}
@Test void invalidSourcePreventsAnyOutboundInsert() {
@@ -47,6 +47,26 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CargoDiscardServiceTest {
/** 历史包裹候选必须沿用签收 PK 码,不能展示内部 RP 码。 */
@Test
void parcelCandidatesAndDetailsUseReceiptLabel() {
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("TT-BYWL-261019180631");
carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5");
carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setStatus("AVAILABLE");
var sku = new ReturnInventoryCartonItem(); sku.setSkuCode("SKU"); sku.setQuantity(6L);
when(returnCartons.findAvailable("100", null)).thenReturn(List.of(carton));
when(returnCartons.selectById(901L)).thenReturn(carton);
when(returnCartonItems.listCarton(901L)).thenReturn(List.of(sku));
var candidates = service.candidates("100", "PK-G1OTH7E5RRB5");
assertEquals(1, candidates.size()); assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartonMark());
assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartons().get(0).cartonMark());
storedApplication.set(pendingApplication());
var line = item(); line.setSourceType("RETURN_CARTON"); line.setReturnCartonId(901L); storedItems.add(line);
var detail = service.detail("100", "FH-1");
assertEquals(List.of("PK-G1OTH7E5RRB5"), detail.items().get(0).cartonMarks());
}
@Test
void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() {
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -112,20 +112,24 @@ class CargoDiscardStockServiceTest {
verifyNoInteractions(commands, items, links);
}
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() {
@Test void receivedTransferBoxesAreSelectableAndReleaseBackToCountedStatus() {
position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER");
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second));
when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1);
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
assertTrue(candidate.cartonSelectionSupported());
assertEquals(List.of("WT-1#2-1", "WT-1#2-2"), candidate.cartons().stream().map(box -> box.cartonMark()).toList());
when(stock.changeTransferCartonStatus("C1", 2L, "COUNTED", "DISCARD_PENDING")).thenReturn(1);
service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
assertEquals("TRANSFER", capture.getValue().getSourceType());
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue()));
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1);
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED")).thenReturn(1);
var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
service.release("C1", application, line.getValue());
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED");
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED");
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
@@ -10,6 +10,19 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*;
class ProcurementRequestBoundaryTest {
@Test void purchaseCategoryMayBeOmittedOrBlankButCannotExceedLimit() throws Exception {
var json = new ObjectMapper();
try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
for (String category : List.of("null", "\"\"", "\"sample\"")) {
var request = json.readValue("{\"purchaseCategory\":" + category + "}", OverseasProcurementRequests.Create.class);
assertThat(validator.validate(request)).noneMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString()));
}
var oversized = json.readValue("{\"purchaseCategory\":\"" + "x".repeat(129) + "\"}", OverseasProcurementRequests.Create.class);
assertThat(validator.validate(oversized)).anyMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString()));
}
}
@Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() {
new org.springframework.boot.test.context.runner.ApplicationContextRunner()
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
@@ -185,7 +185,7 @@ class ReturnInventoryServiceTest {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
var source = service.createReceiptCarton(completedOrder(), receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
assertThat(source.getCartonMark()).startsWith("RP-").hasSizeLessThanOrEqualTo(16);
assertThat(source.getCartonMark()).isEqualTo("PK-1");
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
verifyNoInteractions(numbers);
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
@@ -56,6 +56,16 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReturnReceiptServiceTest {
@Test void rejectsUnprintablePhysicalCodesBeforeReceiptWrites() {
for (String code : List.of("中文包裹", "PK-中文")) {
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "RETURN_NO", null,
List.of(new PdaReturnReceiptRequests.Item("SKU1", 1)), "INVALID-CODE", "PARCEL", code,
java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
assertThat(assertThrows(ResponseStatusException.class, () -> service.confirm(identity, request)).getReason())
.contains("实物编码无法生成条码");
}
verifyNoInteractions(orders, receipts, orderItems, receiptItems, inventory);
}
@Test void countedCargoReplacesForecastTotalsAndRepeatedSyncUpdatesTheSameDetail() {
var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class);
ReflectionTestUtils.setField(service, "lastlegOrderDetails", details);
@@ -268,6 +278,8 @@ class ReturnReceiptServiceTest {
assertEquals("RL-MULTI", order.getRelabelNo());
var application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(relabelApplications).insert(application.capture());
assertEquals("", application.getValue().getContactName());
assertEquals("", application.getValue().getContactPhone());
assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue());
assertEquals(2, application.getValue().getItemCount());
var lines = ArgumentCaptor.forClass(RelabelItem.class);
@@ -637,3 +649,4 @@ class ReturnReceiptServiceTest {
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true);
}
}
@@ -36,6 +36,16 @@ class TransferInventorySourceTest {
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)");
db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)");
db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)");
db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR,order_id BIGINT,profile_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,product_quantity BIGINT,status VARCHAR)");
db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR,carton_id BIGINT,sku_code VARCHAR,product_quantity BIGINT)");
db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR,inventory_position_id BIGINT,status VARCHAR)");
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,available_packages BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,available_packages BIGINT)");
db.update("INSERT INTO ll_dropship_order_profile VALUES(101,'A',201,'ACTIVE')");
db.update("INSERT INTO ll_inventory_position VALUES(201,'A',301,2)");
db.update("INSERT INTO ll_inventory_balance VALUES(301,'A',2)");
db.update("INSERT INTO ll_dropship_carton VALUES(401,'A',1,101,'DROPSHIP','SKU',5,'AVAILABLE'),(402,'A',1,101,'SHIPPED','SKU',5,'OUTBOUNDED')");
db.update("INSERT INTO ll_dropship_carton_sku VALUES('A',401,'SKU',3),('A',401,'SKU-B',2)");
db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())");
@@ -47,9 +57,13 @@ class TransferInventorySourceTest {
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN");
assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals("ONT8",result.get(0).destinationWarehouseCode());
assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity());
assertEquals(List.of("DROPSHIP", "FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals(5L,result.get(0).quantity()); assertTrue(result.get(0).skuItemsJson().contains("SKU"));
assertTrue(result.get(0).skuItemsJson().contains("SKU-B"), "混装箱内所有 SKU 均须保留,箱子只返回一次");
assertEquals("ONT8",result.get(1).destinationWarehouseCode());
assertNull(result.get(1).quantity()); assertEquals(20L,result.get(2).quantity());
db.update("UPDATE ll_inventory_position SET available_packages=0"); session.clearCache();
assertEquals(List.of("FIRST", "MEASURED"),session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN").stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
}
}
}
@@ -175,6 +175,18 @@ class WarehouseTransferCartonCountTest {
ia.setDeclaredQuantity(4L);
assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib)));
}
/** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */
@Test void missingDeclarationIsUnknownButKnownSkuDifferenceStillRequiresReason() {
var unknown = new WarehouseTransferItem(); unknown.setId(10L); unknown.setSkuCode("A");
var known = new WarehouseTransferItem(); known.setId(11L); known.setSkuCode("B"); known.setDeclaredQuantity(7L);
a.setSkuItemsJson("[{\"transferItemId\":10,\"skuCode\":\"A\",\"quantity\":4},{\"transferItemId\":11,\"skuCode\":\"B\",\"quantity\":7}]");
assertFalse(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known)));
known.setDeclaredQuantity(8L);
assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known)));
known.setDeclaredQuantity(7L); unknown.setDeclaredQuantity(0L);
assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known)));
}
/** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */
@Test void extraReceiptRequiresForecastCartonFirst() {
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,List.of(new WarehouseTransferRequests.CountSku("B",4)));
@@ -521,6 +521,8 @@ 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);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.workflow;
import org.junit.jupiter.api.Test;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.mybatis.spring.SqlSessionTemplate;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 通过真实Mapper验证出库取消和客户隔离,使用隔离内存库。 */
class OrderWorkflowFactMapperTest {
/** 有效出库、取消、实出库和差异卸柜均由持久化事实决定。 */
@Test void cancellationAndCustomerAttribution() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workflow-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(30),source_inbound_id VARCHAR(30),source_region VARCHAR(20),order_type VARCHAR(30))");
db.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR(30),source_order_id BIGINT,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),dispatch_item_id BIGINT)");
db.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatch_order_id BIGINT)");
db.execute("CREATE TABLE ll_delivery_order(outbound_order_id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatched_at TIMESTAMP,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))");
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(30),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inbound_plan(customer_id VARCHAR(30),source_order_id BIGINT,status VARCHAR(40))");
db.update("INSERT INTO ll_order VALUES(1,'11','101','FIRSTLEG','FBA'),(2,'22','101','FIRSTLEG','FBA')");
db.update("INSERT INTO ll_outbound_order VALUES(9,'11','WAIT_OUTBOUND',50),(10,'22','WAIT_OUTBOUND',51)");
db.update("INSERT INTO ll_outbound_pallet_item VALUES(9,'11',1,'FBA1','3',60),(10,'22',2,'FBA1','3',61)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(OrderWorkflowFactMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(OrderWorkflowFactMapper.class);
var sources=List.of(new OrderWorkflowFacts.Source(11,101));
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(11);assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isTrue();});
db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=9");
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isFalse();assertThat(f.pendingOutbound()).isFalse();});
db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=9");
db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)");
db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')");
assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();});
db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'");
db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')");
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());
db.update("UPDATE ll_dispatch_item SET customer_id='22'");
assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isFalse());
assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(33,101)))).isEmpty();
// 相邻雪花ID超过浮点安全范围,必须按字符串精确匹配客户和来源。
db.update("INSERT INTO ll_order VALUES(3,'2092814835845664769','2092814880129130499','FIRSTLEG','FBA'),(4,'2092814835845664768','2092814880129130499','FIRSTLEG','FBA'),(5,'2092814835845664769','2092814880129130498','FIRSTLEG','FBA')");
assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(2092814835845664769L,2092814880129130499L))))
.singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(2092814835845664769L);assertThat(f.inboundId()).isEqualTo(2092814880129130499L);});
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.workflow;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 内部事实入口不能被缺失或错误服务身份读取。 */
class OrderWorkflowSecurityTest {
/** 无效服务令牌在仓储查询前被拒绝。 */
@Test void rejectsInvalidServiceIdentityBeforeReadingFacts() {
var service=mock(OrderWorkflowFactService.class);
var controller=new InternalOrderWorkflowFactController(new InternalServiceTokenGuard("test-service-token"),service);
var request=new OrderWorkflowFacts.Request(List.of(new OrderWorkflowFacts.Source(11,101)));
assertThatThrownBy(()->controller.read(null,request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThatThrownBy(()->controller.read("wrong-token",request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
verifyNoInteractions(service);
when(service.read(request)).thenReturn(List.of());
assertThat(controller.read("test-service-token",request).successful()).isTrue();
verify(service).read(request);
}
}