package com.baiyue.lastleg.dispatch; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; import java.sql.DriverManager; import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ @Test void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { var db = connection.createStatement(); db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)"); db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); for (int id = 1; id <= 9; id++) { db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")"); if (id != 2) db.execute("INSERT INTO ll_container_shipping_gate VALUES (" + id + ",40,CURRENT_TIMESTAMP)"); } db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=5 WHERE id IN (3,4)"); db.execute("INSERT INTO ll_inventory_position VALUES ('A',103,5),('A',104,0),('B',104,10)"); db.execute("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=5"); db.execute("UPDATE ll_order SET manual_node_override=TRUE WHERE id IN (6,7,9)"); db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); String plan = DispatchCandidateMapper.PLAN_ONLY; String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; var ids = new ArrayList(); try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { while (rows.next()) { long id = rows.getLong("id"); ids.add(id); assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); } } assertEquals(List.of(1L, 3L, 7L), ids); // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); } } } /** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */ @Test void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception { String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class, String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value()); int start = sql.indexOf("CAST(CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id)"); int end = sql.indexOf(" AS overseasWarehouseId", start); assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影"); String expression = sql.substring(start, end).replace(">", ">").replace("<", "<"); assertTrue(expression.endsWith("END AS CHAR(128))")); assertTrue(expression.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')")); String warehouseFilter = sql.substring(sql.lastIndexOf(") selected WHERE ") + 1) .replace("#{warehouseId}", "?").replace("", ""); String query = "SELECT selected.planPalletId FROM (SELECT p.id AS planPalletId," + expression + " AS overseasWarehouseId FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id)" + warehouseFilter; try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) { var statement = connection.createStatement(); statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)"); statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))"); statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))"); statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL)," + "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL)," + "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21)," + "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)"); statement.execute("INSERT INTO ll_inbound_plan_pallet(id,plan_id,warehouse_pallet_id) VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)"); statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (10,1,1000,'RESERVED',0)"); statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001')," + "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001')," + "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')"); statement.execute("INSERT INTO ll_order VALUES (20,'A','193000000000200002')," + "(20,'B','193000000000100001'),(21,'B','193000000000100001')"); try (var prepared = connection.prepareStatement(query)) { assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L)); assertWarehouseRows(prepared, "193000000000200002", List.of(8L, 10L)); for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong")) assertWarehouseRows(prepared, invalid, List.of()); } } } /** 每次实际执行同一参数化仓过滤,禁止用前缀、数字强转或其他客户库存替代身份。 */ private void assertWarehouseRows(java.sql.PreparedStatement prepared, String warehouse, List expected) throws Exception { prepared.setString(1, warehouse); var actual = new ArrayList(); try (var rows = prepared.executeQuery()) { while (rows.next()) actual.add(rows.getLong(1)); } actual.sort(Long::compareTo); assertEquals(expected, actual, warehouse); } @Test void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception { for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) { String sql = String.join(" ", DispatchCandidateMapper.class .getMethod(method, String.class, String.class, String.class).getAnnotation(Select.class).value()); String eligibility = sql.substring(sql.indexOf("WHERE a.delivery_method"), sql.indexOf("(); while (result.next()) palletIds.add(result.getLong(1)); assertEquals(List.of(12L, 13L, 14L), palletIds, method.getName()); } } } private static final List CONSTRUCTOR_ALIASES = List.of( "planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName", "planNo", "orderType", "volumeWeightKg", "grossWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", "overseasWarehouseId", "overseasWarehouseCode", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", "destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode", "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks", "destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone" ); @Test void candidateQueriesKeepRecordConstructorColumnOrder() throws Exception { assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class)); assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class)); assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class)); } @Test void selectedReceivedPalletMustStillHaveAvailableInventory() throws Exception { Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class); String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("AND (" + DispatchCandidateMapper.PLAN_ONLY + " OR EXISTS")); assertTrue(sql.contains("pos.available_packages > 0")); } @Test void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception { for (Method method : List.of( DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL")); } } @Test void candidateKeywordSearchIncludesSourceInboundCode() throws Exception { Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class); String availableSql = String.join(" ", available.getAnnotation(Select.class).value()); assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')")); Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class); String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value()); assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')")); } @Test void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception { for (Method method : List.of( DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY)); assertTrue(sql.contains("pos.overseas_warehouse_id")); assertTrue(sql.contains(method.getName().equals("available") ? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')" : "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')")); assertTrue(sql.contains("source_order.id=ip.source_order_id")); assertTrue(sql.contains("source_order.customer_id=ip.customer_id")); assertTrue(sql.contains("source_order.overseas_warehouse_id")); assertTrue(sql.contains("AS overseasWarehouseCode")); assertTrue(sql.contains("b.overseas_warehouse_code")); assertTrue(sql.contains("ll_inventory_balance")); } } @Test void candidateMarksFollowPdaRelabelPalletBinding() throws Exception { for (Method method : List.of( DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("rp.id=ri.source_inventory_position_id")); assertTrue(sql.contains("rp.pallet_id=p.warehouse_pallet_id")); assertTrue(sql.contains("ri.fba_carton_mark")); assertTrue(sql.contains("AS relabelCartonMarks")); } } @Test void relabelCandidatesReadWeightFromWarehouseTransferPalletSnapshot() throws Exception { String sql=String.join(" ",DispatchCandidateMapper.class.getMethod("relabelAvailable",String.class,String.class,String.class).getAnnotation(Select.class).value()); assertTrue(sql.contains("MAX(wtp.gross_weight_kg) AS grossWeightKg")); assertTrue(sql.contains("MAX(wtp.volume_cbm) AS volumeCbm")); assertTrue(sql.contains("wtp.warehouse_pallet_id=pos.pallet_id")); } @Test void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception { for (Method method : List.of( DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("SUM(d.gross_weight_kg)")); assertTrue(sql.contains("inbound.gross_weight_kg")); assertTrue(sql.contains("ll_inbound_order inbound")); assertTrue(sql.contains("a.allocated_volume_cbm")); assertTrue(sql.contains("a2.line_id=a.line_id")); assertFalse(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg")); } } private void assertConstructorColumnOrder(Method method) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); int previous = -1; for (String alias : CONSTRUCTOR_ALIASES) { int current = sql.indexOf(" AS " + alias, previous + 1); assertTrue(current > previous, () -> method.getName() + " must select alias " + alias + " in record constructor order"); previous = current; } } }