Files
baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java
T

265 lines
18 KiB
Java
Raw Normal View History

2026-08-21 17:21:26 +08:00
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;
2026-10-14 13:42:58 +08:00
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.util.ArrayList;
2026-08-21 17:21:26 +08:00
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;
2026-08-21 17:21:26 +08:00
class DispatchCandidateMapperContractTest {
@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("<if test='customerId"));
try (var connection = DriverManager.getConnection("jdbc:h2:mem:relabel-mode-" + method + ";MODE=MySQL")) {
var statement = connection.createStatement();
statement.execute("CREATE TABLE ll_relabel_application(delivery_method VARCHAR,status VARCHAR,fulfillment_status VARCHAR)");
statement.execute("INSERT INTO ll_relabel_application VALUES "
+ "('TRUCK','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('AMAZON_PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('CUSTOMER_PICKUP','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('UNKNOWN','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "(NULL,'READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH'),"
+ "('EXPRESS','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH')");
// The return query also requires physical return-carton availability.
eligibility = eligibility.replace("AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL", "");
try (var rows = statement.executeQuery("SELECT delivery_method FROM ll_relabel_application a " + eligibility)) {
assertTrue(rows.next()); assertEquals("TRUCK", rows.getString(1)); assertFalse(rows.next());
}
}
}
}
@Test
void confirmedAppointmentUsesOriginalCustomerOrderAndDestinationAndNeverInventsLoadingTime() throws Exception {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-confirmed-appointment;MODE=MySQL")) {
var statement = connection.createStatement();
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT, customer_id VARCHAR)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT, plan_id BIGINT,destination_warehouse_code VARCHAR)");
statement.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,fba_warehouse_code VARCHAR)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR)");
statement.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_at TIMESTAMP,planned_at TIMESTAMP,appointment_status VARCHAR,fba_warehouse_code VARCHAR)");
statement.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR,active INT,fba_warehouse_code VARCHAR)");
for (int i = 1; i <= 8; i++) {
statement.execute("INSERT INTO ll_inbound_plan VALUES ("+i+",'9')");
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES ("+i+","+i+",'ACY2')");
statement.execute("INSERT INTO ll_inbound_plan_line VALUES ("+i+","+i+",'9',"+i+",'OLD')");
statement.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES ("+i+","+i+",'9')");
statement.execute("INSERT INTO ll_inbound_fba_appointment VALUES ("+i+",'2026-10-12 10:00:00','2026-10-11 09:00:00','SUCCESS','ACY2')");
statement.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES ("+i+","+i+",'9',1,'ACY2')");
}
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_status='PENDING' WHERE id=2");
statement.execute("UPDATE ll_inbound_fba_appointment_item SET active=0 WHERE appointment_id=3");
statement.execute("UPDATE ll_inbound_fba_appointment_item SET customer_id='OTHER' WHERE appointment_id=4");
statement.execute("UPDATE ll_inbound_plan_pallet SET destination_warehouse_code='ONT8' WHERE id=5");
statement.execute("UPDATE ll_inbound_plan_line SET customer_id='OTHER' WHERE id=6");
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_at=NULL WHERE id=7");
statement.execute("UPDATE ll_inbound_fba_appointment_item SET fba_warehouse_code='ONT8' WHERE appointment_id=8");
try (var rows = statement.executeQuery("SELECT p.id," + DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT
+ " AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id ORDER BY p.id")) {
int count = 0;
while (rows.next()) {
count++;
assertEquals(count == 1 ? java.sql.Timestamp.valueOf("2026-10-12 10:00:00") : null, rows.getTimestamp("appointment_at"));
}
assertEquals(8, count);
}
statement.execute("INSERT INTO ll_inbound_fba_appointment VALUES (99,'2026-10-15 10:00:00',NULL,'SUCCESS','ACY2')");
statement.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES (99,1,'9',1,'ACY2')");
try(var rows=statement.executeQuery("SELECT "+DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT+" AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=1")) {
assertTrue(rows.next());assertEquals(null,rows.getTimestamp("appointment_at"),"一票多批时不得将最晚确认冒充整个托盘的预约时间");
}
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_status='PENDING' WHERE id=99");
try(var rows=statement.executeQuery("SELECT "+DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT+" AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=1")) {
assertTrue(rows.next());assertEquals(null,rows.getTimestamp("appointment_at"),"部分批次确认也不能代表整票");
}
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(DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt"));
assertFalse(sql.contains("expected_loading_at"));
assertFalse(sql.contains("fi.line_id"));
}
}
}
@Test
void historicalTransferCreationModeDoesNotGrantReservationOwnership() 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("AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ip.creation_mode='WAREHOUSE_TRANSFER' OR NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation"));
}
}
@Test
void availableAndLockedPalletsApplyTheSameActiveRelabelBoundary() throws Exception {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-relabel-boundary;MODE=MySQL")) {
var statement = connection.createStatement();
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT, customer_id VARCHAR, creation_mode VARCHAR)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT, plan_id BIGINT)");
statement.execute("CREATE TABLE ll_order(id BIGINT)");
statement.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT, order_id BIGINT)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT, line_id BIGINT)");
statement.execute("CREATE TABLE ll_relabel_application(id BIGINT, customer_id VARCHAR, status VARCHAR)");
statement.execute("CREATE TABLE ll_relabel_item(application_id BIGINT, customer_id VARCHAR, source_inbound_order_id BIGINT)");
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'100','AUTO'),(2,'100','AUTO'),(3,'100','WAREHOUSE_TRANSFER'),(4,'100','AUTO'),(5,'100','AUTO')");
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (11,1),(12,2),(13,3),(14,4),(15,5)");
statement.execute("INSERT INTO ll_order VALUES (21),(22),(23),(24),(25),(26)");
statement.execute("INSERT INTO ll_inbound_plan_line VALUES (31,21),(32,22),(33,23),(34,24),(35,25),(36,26)");
statement.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (11,31),(12,32),(13,33),(14,34),(15,35),(15,36)");
statement.execute("INSERT INTO ll_relabel_application VALUES (41,'100','PENDING_REVIEW'),(42,'100','COMPLETED'),(43,'100','PENDING_REVIEW'),(44,'200','PENDING_REVIEW'),(45,'100','PENDING_REVIEW')");
statement.execute("INSERT INTO ll_relabel_item VALUES (41,'100',21),(42,'100',22),(43,'100',23),(44,'200',24),(45,'100',25)");
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());
String boundary = sql.substring(sql.lastIndexOf("AND (ip.creation_mode"), sql.lastIndexOf("GROUP BY"))
.replace("#{customerId}", "'100'");
var result = statement.executeQuery("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id "
+ "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id "
+ "JOIN ll_order o ON o.id=l.order_id WHERE 1=1 " + boundary + " ORDER BY p.id");
var palletIds = new ArrayList<Long>();
while (result.next()) palletIds.add(result.getLong(1));
assertEquals(List.of(12L, 13L, 14L), palletIds, method.getName());
}
}
}
2026-08-21 17:21:26 +08:00
private static final List<String> CONSTRUCTOR_ALIASES = List.of(
"planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName",
2026-09-18 11:41:04 +08:00
"planNo", "orderType", "volumeWeightKg", "grossWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
"overseasWarehouseId", "overseasWarehouseCode", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
2026-08-21 17:21:26 +08:00
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
2026-09-17 17:37:29 +08:00
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks",
"destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone"
2026-08-21 17:21:26 +08:00
);
@Test
void candidateQueriesKeepRecordConstructorColumnOrder() throws Exception {
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("available",
2026-08-22 10:20:38 +08:00
String.class, List.class, String.class, String.class, String.class, String.class));
2026-08-21 17:21:26 +08:00
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("lockSelected",
String.class, List.class));
2026-09-14 09:58:30 +08:00
assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("relabelAvailable",
String.class, String.class, String.class));
2026-08-21 17:21:26 +08:00
}
@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("p.warehouse_pallet_id IS NULL OR EXISTS"));
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
2026-09-17 17:37:29 +08:00
@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},'%')"));
}
2026-09-07 15:07:34 +08:00
@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 p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
2026-09-18 11:41:04 +08:00
assertTrue(sql.contains("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"));
2026-09-07 15:07:34 +08:00
assertTrue(sql.contains("ll_inventory_balance"));
}
}
2026-09-14 09:58:30 +08:00
@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"));
}
}
2026-10-14 13:42:58 +08:00
@Test
void relabelCandidatesReadWeightFromWarehouseTransferPalletSnapshot() throws Exception {
String source = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java"));
int start = source.indexOf("List<DispatchPalletCandidate> relabelAvailable");
int end = source.indexOf("List<DispatchPalletCandidate> nativeReturnRelabelAvailable");
String sql = source.substring(source.lastIndexOf("@Select", start), end);
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"));
}
2026-09-18 11:41:04 +08:00
@Test
void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception {
2026-09-18 11:41:04 +08:00
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"));
2026-09-18 11:41:04 +08:00
}
}
2026-08-21 17:21:26 +08:00
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;
}
}
}