fix(firstleg): settle and validate actual partial loading cargo
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
package com.baiyue.firstleg.customs.container;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** Real mapper checks: customs must use physical loading scope, not retained skipped plan rows. */
|
||||
class ContainerCustomsActualCargoTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private ContainerCustomsMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:customs-loaded-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,plan_status VARCHAR,confirmed_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,record_status VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT,package_count INT,customer_name VARCHAR,order_no VARCHAR,inbound_code VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,product_quantity INT,gross_weight_kg DECIMAL,net_weight_kg DECIMAL,volume_cbm DECIMAL,shipping_mark VARCHAR,customer_remark VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,customer_name VARCHAR,tax_mode VARCHAR,deleted INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT)");
|
||||
jdbc.execute("CREATE TABLE fl_customs_customer_document_inbound(document_id BIGINT,inbound_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,customs_container_id BIGINT,status VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,container_booking_id BIGINT,status VARCHAR,completed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_outbound_dispatch(inbound_id BIGINT,tenant_id BIGINT,container_booking_id BIGINT,status VARCHAR)");
|
||||
jdbc.update("INSERT INTO fl_loading_plan VALUES(1,'PLAN',10,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO fl_container_booking VALUES(10,'CAB','CONTAINER','ACTIVE')");
|
||||
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1),(200,2)");
|
||||
inbound(1, 11, "TAX_REFUND");
|
||||
inbound(2, 22, "TAX_REFUND");
|
||||
inbound(3, 11, "BUY_DECLARATION");
|
||||
inbound(4, 22, "BUY_DECLARATION");
|
||||
item(1, 1, 11, 2, "PICKED", 2, 100);
|
||||
item(2, 1, 11, 1, "PARTIAL", 1, 100);
|
||||
item(3, 1, 11, 1, "SKIPPED", 0, 100); // same inbound, skipped SKU
|
||||
item(4, 2, 22, 1, "SKIPPED", 0, 100); // entirely skipped customer/inbound
|
||||
item(5, 3, 11, 1, "PICKED", 1, 100);
|
||||
item(6, 4, 22, 1, "SKIPPED", 0, 100);
|
||||
item(7, 2, 22, 1, "PICKED", 1, 200); // different task/plan cannot prove loading
|
||||
item(8, 2, 22, 2, "PARTIAL", 1, 100); // unreconciled allocation
|
||||
item(9, 2, 22, 1, "PICKED", 1, 100);
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=9");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(ContainerCustomsMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(ds);
|
||||
factory.setConfiguration(configuration);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ContainerCustomsMapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualCountsAndInboundScopeUseSettledPicksWithCustomerAndTaxIsolation() {
|
||||
assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(4);
|
||||
assertThat(mapper.actualBoxCount(1, 11L, "TAX_REFUND")).isEqualTo(3);
|
||||
assertThat(mapper.actualBoxCount(1, 22L, "TAX_REFUND")).isZero();
|
||||
assertThat(mapper.planInbounds(1)).extracting(ContainerCustomsMapper.InboundRow::inboundId)
|
||||
.containsExactly(1L, 3L);
|
||||
assertThat(mapper.completedPlanForOrder(11, 1)).isEqualTo(1);
|
||||
assertThat(mapper.completedPlanForOrder(22, 2)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void skippedRefundTicketDoesNotBlockCoverageOrCreateAFalseRefundRequirement() {
|
||||
assertThat(mapper.taxRefundCount(1)).isEqualTo(1);
|
||||
assertThat(mapper.missingRefundCount(1, 90)).isEqualTo(1);
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document VALUES(80,90,'ACTIVE')");
|
||||
jdbc.update("INSERT INTO fl_customs_customer_document_inbound VALUES(80,1)");
|
||||
assertThat(mapper.missingRefundCount(1, 90)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void entirelySkippedRefundCargoLeavesARealBuyDeclarationOnlyContainer() {
|
||||
jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id IN (1,2)");
|
||||
assertThat(mapper.taxRefundCount(1)).isZero();
|
||||
assertThat(mapper.missingRefundCount(1, 90)).isZero();
|
||||
assertThat(mapper.actualBoxCount(1, null, null)).isEqualTo(1);
|
||||
assertThat(mapper.planInbounds(1)).singleElement().satisfies(row ->
|
||||
assertThat(row.taxMode()).isEqualTo("BUY_DECLARATION"));
|
||||
assertThat(mapper.completedPlanForOrder(11, 1)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void buyExportCursorCountAndRowsExcludeSkippedCargoConsistently() {
|
||||
assertThat(mapper.maximumBuyItemId(1)).isEqualTo(5);
|
||||
assertThat(mapper.countBuyRows(1, 100)).isEqualTo(1);
|
||||
assertThat(mapper.buyRows(1, 0, 100, 20)).singleElement().satisfies(row -> {
|
||||
assertThat(row.itemId()).isEqualTo(5);
|
||||
assertThat(row.packageCount()).isEqualTo(1);
|
||||
});
|
||||
assertThat(mapper.buyRows(1, 5, 100, 20)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingDispatchIgnoresSkippedRowsButStillFindsUndispatchedActualCargo() throws Exception {
|
||||
jdbc.update("INSERT INTO fl_customs_container VALUES(90,1,10,'COMPLETED',CURRENT_TIMESTAMP)");
|
||||
// H2 cannot bind the outer alias inside this LEFT JOIN ON. Bind only that
|
||||
// correlation to this fixture's container; execute the remaining production SQL unchanged.
|
||||
String sql = String.join("\n", ContainerCustomsMapper.class.getMethod("pendingAutoDispatch", int.class)
|
||||
.getAnnotation(org.apache.ibatis.annotations.Select.class).value())
|
||||
.replace("d.container_booking_id=c.container_booking_id", "d.container_booking_id=10")
|
||||
.replace("#{limit}", "20");
|
||||
assertThat(jdbc.queryForList(sql, Long.class)).containsExactly(10L);
|
||||
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(1,11,10,'WAITING_FOR_LOADING'),(3,11,10,'WAITING_FOR_LOADING')");
|
||||
assertThat(jdbc.queryForList(sql, Long.class)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preCompletionInboundPreparationKeepsItsPlannedScopeAndNoActualBoxCount() {
|
||||
jdbc.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=1");
|
||||
assertThat(mapper.planInbounds(1)).hasSize(4);
|
||||
assertThat(mapper.actualBoxCount(1, null, null)).isZero();
|
||||
assertThat(mapper.completedPlanForOrder(11, 1)).isNull();
|
||||
}
|
||||
|
||||
private void inbound(long id, long tenant, String taxMode) {
|
||||
jdbc.update("INSERT INTO fl_inbound_order VALUES(?,?,?,'ORDER','INBOUND','TEST',?,0)", id, tenant, id, taxMode);
|
||||
}
|
||||
|
||||
private void item(long id, long inbound, long tenant, int packages, String status, int picked, long task) {
|
||||
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,inbound_id,account_id,deleted,package_count,product_quantity) VALUES(?,1,?,?,0,?,?)",
|
||||
id, inbound, tenant, packages, packages * 10);
|
||||
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,?)", id, task, status, picked);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** Persist real SET expressions; adapt only MySQL UPDATE JOIN to H2 MERGE syntax. */
|
||||
class PartialLoadingMetricsPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private String update;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:partial-metrics-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),unit_type VARCHAR,pallet_id BIGINT,deleted INT,updated_by BIGINT,updated_by_name VARCHAR,updated_at TIMESTAMP,version INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT,planned_package_count INT,picked_package_count INT,item_status VARCHAR)");
|
||||
String mysql = String.join("\n", InboundSplitSettlementMapper.class
|
||||
.getMethod("convergePartialPlanItem", long.class, long.class, String.class)
|
||||
.getAnnotation(Update.class).value());
|
||||
String assignments = mysql.substring(mysql.indexOf("SET ") + 4, mysql.indexOf("WHERE "));
|
||||
String conditions = mysql.substring(mysql.indexOf("WHERE ") + 6);
|
||||
update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item "
|
||||
+ "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + conditions
|
||||
+ ") THEN UPDATE SET " + assignments)
|
||||
.replace("#{operatorId}", "9").replace("#{operatorName}", "'TEST'").replace("#{planItemId}", "?");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneActualCartonUsesItsFrozenUnitsAndHalfTheTwoCartonMetrics() {
|
||||
seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234");
|
||||
assertThat(jdbc.update(update, 1)).isEqualTo(1);
|
||||
assertMetrics(1, 1, 12, 1, "12.500", "11.000", "9.250", "0.0617");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonHalfRatioUsesDeclaredUnitsAndDatabasePrecisionWithoutTruncation() {
|
||||
seed(1, 3, 2, 7, 21, 2, "10.001", "8.002", "6.004", "0.1001");
|
||||
assertThat(jdbc.update(update, 1)).isEqualTo(1);
|
||||
assertMetrics(1, 2, 14, 2, "6.667", "5.335", "4.003", "0.0667");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingUnitsUsesFrozenQuantityRatioAndPreservesUnknownMeasures() {
|
||||
seed(1, 3, 2, null, 10, null, null, null, null, null);
|
||||
assertThat(jdbc.update(update, 1)).isEqualTo(1);
|
||||
assertMetrics(1, 2, 7, 0, null, null, null, null);
|
||||
seed(2, 3, 2, null, null, null, null, null, null, null);
|
||||
assertThat(jdbc.update(update, 2)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT product_quantity FROM fl_loading_plan_item WHERE id=2", Integer.class)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyConvergedRowDoesNotScaleAgainOrAdvanceVersion() {
|
||||
seed(1, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234");
|
||||
assertThat(jdbc.update(update, 1)).isEqualTo(1);
|
||||
var after = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1");
|
||||
assertThat(jdbc.update(update, 1)).isZero();
|
||||
assertThat(jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=1")).isEqualTo(after);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingSkippedFullAndPalletUnitsCannotEnterPartialConvergence() {
|
||||
for (int id = 1; id <= 4; id++) seed(id, 2, 1, 12, 24, 2, "25", "22", "18.5", "0.1234");
|
||||
jdbc.update("UPDATE fl_loading_pick_item SET item_status='PENDING' WHERE loading_plan_item_id=1");
|
||||
jdbc.update("UPDATE fl_loading_pick_item SET item_status='SKIPPED',picked_package_count=0 WHERE loading_plan_item_id=2");
|
||||
jdbc.update("UPDATE fl_loading_pick_item SET item_status='PICKED',picked_package_count=2 WHERE loading_plan_item_id=3");
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET',pallet_id=99 WHERE id=4");
|
||||
var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id");
|
||||
for (int id = 1; id <= 4; id++) assertThat(jdbc.update(update, id)).isZero();
|
||||
assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before);
|
||||
}
|
||||
|
||||
private void seed(long id, int planned, int picked, Integer units, Integer quantity, Integer pallets,
|
||||
String gross, String net, String dimensional, String volume) {
|
||||
jdbc.update("INSERT INTO fl_loading_plan_item VALUES(?,?,?,?,?,?,?,?,?,'PACKAGE',NULL,0,NULL,NULL,NULL,0)",
|
||||
id, planned, quantity, units, pallets, decimal(gross), decimal(net), decimal(dimensional), decimal(volume));
|
||||
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,'PARTIAL')", id, planned, picked);
|
||||
}
|
||||
|
||||
private void assertMetrics(long id, int boxes, Integer quantity, Integer pallets,
|
||||
String gross, String net, String dimensional, String volume) {
|
||||
var row = jdbc.queryForMap("SELECT * FROM fl_loading_plan_item WHERE id=?", id);
|
||||
assertThat(row.get("PACKAGE_COUNT")).isEqualTo(boxes);
|
||||
assertThat(row.get("PRODUCT_QUANTITY")).isEqualTo(quantity);
|
||||
assertThat(row.get("PALLET_COUNT")).isEqualTo(pallets);
|
||||
assertThat((BigDecimal) row.get("GROSS_WEIGHT_KG")).isEqualTo(decimal(gross));
|
||||
assertThat((BigDecimal) row.get("NET_WEIGHT_KG")).isEqualTo(decimal(net));
|
||||
assertThat((BigDecimal) row.get("DIMENSIONAL_WEIGHT_KG")).isEqualTo(decimal(dimensional));
|
||||
assertThat((BigDecimal) row.get("VOLUME_CBM")).isEqualTo(decimal(volume));
|
||||
}
|
||||
|
||||
private BigDecimal decimal(String value) { return value == null ? null : new BigDecimal(value); }
|
||||
}
|
||||
+33
-1
@@ -16,6 +16,38 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaSplitSettlementSqlContractTest {
|
||||
@Test
|
||||
void partialMetricsGuardStaysIdenticalInModuleBaselineAndControlledUpdate() throws Exception {
|
||||
String module = new ClassPathResource("db/phase3-smart-loading-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
|
||||
String update = Files.readString(Path.of("..", "deploy", "sql", "migrations", "cn",
|
||||
"baiyue_firstleg", "V7__settle_partial_loaded_cargo.sql"));
|
||||
String trigger = loadingItemGuard(module);
|
||||
assertThat(loadingItemGuard(unified)).isEqualTo(trigger);
|
||||
assertThat(loadingItemGuard(update)).isEqualTo(trigger);
|
||||
assertThat(trigger)
|
||||
.contains("actual_task.loading_plan_id = OLD.loading_plan_id")
|
||||
.contains("OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL")
|
||||
.contains("OLD.package_count = actual_pick.planned_package_count")
|
||||
.contains("actual_pick.picked_package_count < actual_pick.planned_package_count")
|
||||
.contains("NEW.package_count = actual_pick.picked_package_count")
|
||||
.contains("NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL")
|
||||
.contains("NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)")
|
||||
.contains("NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)")
|
||||
.contains("IF OLD.frozen_at IS NOT NULL THEN")
|
||||
.contains("装柜完成后的明细永久冻结")
|
||||
.contains("NEW.inbound_detail_id <=> OLD.inbound_detail_id");
|
||||
}
|
||||
|
||||
private static String loadingItemGuard(String sql) {
|
||||
int start = sql.indexOf("CREATE TRIGGER trg_loading_item_before_update");
|
||||
assertThat(start).isGreaterThanOrEqualTo(0);
|
||||
int end = sql.indexOf("END$$", start);
|
||||
assertThat(end).isGreaterThan(start);
|
||||
return sql.substring(start, end + 5).replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception {
|
||||
String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql")
|
||||
@@ -133,7 +165,7 @@ class PdaSplitSettlementSqlContractTest {
|
||||
.getAnnotation(Update.class).value()[0];
|
||||
assertThat(converge)
|
||||
.contains("plan_item.pallet_count = CEIL")
|
||||
.contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)")
|
||||
.contains("pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)")
|
||||
.contains("plan_item.package_count = pick_item.picked_package_count");
|
||||
}
|
||||
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
|
||||
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
|
||||
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Real annotated Mapper query against persisted, not-yet-settled PDA facts. */
|
||||
class LoadingCompletedPreflightDatabaseTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private SqlSession session;
|
||||
private LoadingCompletedEventMapper snapshots;
|
||||
private LoadingCompletedOutboxService service;
|
||||
private final LoadingPalletLabelService labels = mock(LoadingPalletLabelService.class);
|
||||
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class);
|
||||
|
||||
@BeforeEach void setup() {
|
||||
var ds = new JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:loading-preflight-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_no VARCHAR,container_booking_id BIGINT,version INT)");
|
||||
jdbc.execute("CREATE TABLE fl_container_booking(id BIGINT,container_no VARCHAR,cabinet_no VARCHAR,warehouse_id BIGINT,warehouse_name VARCHAR,estimated_port_arrival_at TIMESTAMP,actual_port_arrival_at TIMESTAMP,estimated_warehouse_arrival_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,root_inbound_id BIGINT,order_id BIGINT,order_no VARCHAR,inbound_code VARCHAR,business_type VARCHAR,fulfillment_type VARCHAR,lx_code VARCHAR,destination_type VARCHAR,destination_warehouse_id BIGINT,destination_code VARCHAR,destination_name VARCHAR,deleted INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,customer_name VARCHAR,sku_code VARCHAR,product_name_zh VARCHAR,product_name_en VARCHAR,package_count INT,product_quantity INT,units_per_carton INT,pallet_count INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),dimensional_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4),fba_tracking_no VARCHAR,shipment_no VARCHAR,shipping_mark VARCHAR,customer_remark VARCHAR,internal_remark VARCHAR,warehouse_remark VARCHAR,deleted INT,unit_type VARCHAR,pallet_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,loading_plan_item_id BIGINT,pick_task_id BIGINT,item_status VARCHAR,picked_package_count INT,planned_package_count INT)");
|
||||
jdbc.execute("ALTER TABLE fl_loading_plan ADD plan_status VARCHAR DEFAULT 'LOADING'");
|
||||
jdbc.execute("ALTER TABLE fl_container_booking ADD record_status VARCHAR DEFAULT 'ACTIVE'");
|
||||
jdbc.execute("ALTER TABLE fl_inbound_order ADD total_package_count INT");
|
||||
jdbc.execute("ALTER TABLE fl_inbound_order ADD pallet_count INT");
|
||||
for (String definition : List.of("inbound_code VARCHAR","destination_warehouse_id BIGINT","destination_warehouse_code VARCHAR",
|
||||
"destination_warehouse_name VARCHAR","load_sequence INT","updated_by BIGINT","updated_by_name VARCHAR","updated_at TIMESTAMP","version INT DEFAULT 0")) {
|
||||
jdbc.execute("ALTER TABLE fl_loading_plan_item ADD " + definition);
|
||||
}
|
||||
jdbc.update("INSERT INTO fl_loading_plan(id,plan_no,container_booking_id,version) VALUES(90,'LP90',91,2)");
|
||||
jdbc.update("INSERT INTO fl_container_booking(id,container_no,cabinet_no,warehouse_id,warehouse_name) VALUES(91,'MSCU6639870','D0091',1,'TEST')");
|
||||
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(92,90)");
|
||||
Configuration config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(LoadingCompletedEventMapper.class);
|
||||
config.addMapper(LoadingPalletLabelMapper.class);
|
||||
session = new SqlSessionFactoryBuilder().build(config).openSession(true);
|
||||
snapshots = session.getMapper(LoadingCompletedEventMapper.class);
|
||||
service = new LoadingCompletedOutboxService();
|
||||
ReflectionTestUtils.setField(service,"snapshotMapper",snapshots);
|
||||
ReflectionTestUtils.setField(service,"cartonManifestMapper",mock(LoadingCartonManifestMapper.class));
|
||||
ReflectionTestUtils.setField(service,"palletLabelMapper",mock(LoadingPalletLabelMapper.class));
|
||||
ReflectionTestUtils.setField(service,"palletLabelService",labels);
|
||||
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
|
||||
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
|
||||
var evidence = mock(CabinetLoadingEvidenceMapper.class);
|
||||
when(evidence.byContainer(anyLong())).thenReturn(List.of(photo("PHOTO_STEP_1"),photo("PHOTO_STEP_2"),photo("PHOTO_STEP_3"),photo("PHOTO_STEP_4"),photo("SEAL")));
|
||||
ReflectionTestUtils.setField(service,"cabinetEvidenceMapper",evidence);
|
||||
// Unrelated sealed-pallet data is empty; the actual plan and pick rows remain real SQL.
|
||||
jdbc.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_status VARCHAR,pallet_no VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,pallet_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,box_code VARCHAR,actual_sku_quantity INT)");
|
||||
jdbc.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR,actual_quantity INT)");
|
||||
jdbc.execute("CREATE TABLE fl_inbound_pallet_allocation(tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,reservation_id BIGINT,pallet_code VARCHAR,allocation_no INT,picked_package_count INT,allocation_status VARCHAR)");
|
||||
for (String definition : List.of("weight_result_id BIGINT","block_id BIGINT","slot_no INT","allocated_package_count INT")) {
|
||||
jdbc.execute("ALTER TABLE fl_inbound_pallet_allocation ADD " + definition);
|
||||
}
|
||||
jdbc.execute("CREATE TABLE fl_inbound_weight_result(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,deleted INT,recognition_status VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pallet_label(id BIGINT,loading_plan_id BIGINT,loading_plan_item_id BIGINT,account_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,container_no VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,pallet_index INT,pallet_code VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE fl_inbound_carton_label(tenant_id BIGINT,inbound_detail_id BIGINT,batch_status VARCHAR,box_code VARCHAR,carton_index INT,id BIGINT)");
|
||||
}
|
||||
|
||||
@AfterEach void close() { session.close(); }
|
||||
|
||||
@Test void allPartialItemsAreValidatedBeforeSettlementWithoutWrites() {
|
||||
seed(1,11,3,2,"PARTIAL","客户A");
|
||||
var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item");
|
||||
service.requirePublishable(90);
|
||||
assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item")).isEqualTo(before);
|
||||
verify(labels,never()).prepare(anyLong(),anyLong());
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
@Test void mixedCustomerPartialCannotDisappearFromReadinessValidation() {
|
||||
seed(1,11,3,3,"PICKED","客户A");
|
||||
seed(2,22,3,2,"PARTIAL",null);
|
||||
assertThatThrownBy(() -> service.requirePublishable(90)).hasMessageContaining("客户名称快照");
|
||||
verify(labels,never()).prepare(anyLong(),anyLong());
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
@Test void invalidSecondCustomersRealPartialSnapshotStopsWorkerBeforeAnySettlement() {
|
||||
seed(1,11,3,3,"PICKED","客户A");
|
||||
seed(2,22,3,2,"PARTIAL",null);
|
||||
var worker = new LoadingPickService();
|
||||
var repository = mock(com.baiyue.firstleg.repository.LoadingPickRepository.class);
|
||||
var settlements = mock(PdaPickSettlementService.class);
|
||||
var list = new LoadingListService();
|
||||
ReflectionTestUtils.setField(list,"loadingCompletedOutboxService",service);
|
||||
ReflectionTestUtils.setField(worker,"repository",repository);
|
||||
ReflectionTestUtils.setField(worker,"settlementService",settlements);
|
||||
ReflectionTestUtils.setField(worker,"loadingListService",list);
|
||||
ReflectionTestUtils.setField(worker,"inboundHoldGuard",mock(InboundHoldGuard.class));
|
||||
ReflectionTestUtils.setField(worker,"cabinetEvidenceService",mock(com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService.class));
|
||||
ReflectionTestUtils.setField(worker,"coloadGroups",mock(com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort.class));
|
||||
var task = new com.baiyue.firstleg.entity.LoadingPickTaskEntity();
|
||||
task.setId(92L); task.setLoadingPlanId(90L); task.setContainerBookingId(91L);
|
||||
when(repository.task(92L)).thenReturn(task);
|
||||
when(repository.inboundOwners(92L)).thenReturn(List.of(
|
||||
new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(11L,1L),
|
||||
new com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundOwner(22L,2L)));
|
||||
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
|
||||
command.setPickTaskId(92L);
|
||||
assertThatThrownBy(() -> worker.processCompletion(command,"test"))
|
||||
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
|
||||
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException)error).stage())
|
||||
.isEqualTo("COMPLETED_EVENT_READY"));
|
||||
verifyNoInteractions(settlements,outbox);
|
||||
}
|
||||
|
||||
@Test void previewUsesFrozenUnitsAndPrecisionButDoesNotScaleSettledRowsTwice() {
|
||||
seed(1,11,3,2,"PARTIAL","客户A");
|
||||
Row row = snapshots.previewRows(90).get(0);
|
||||
LoadingCompletedOutboxService.projectSettledRow(row);
|
||||
assertThat(row.getPackageCount()).isEqualTo(2);
|
||||
assertThat(row.getProductQuantity()).isEqualTo(14);
|
||||
assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667");
|
||||
assertThat(row.getNetWeightKg()).isEqualByComparingTo("5.335");
|
||||
assertThat(row.getDimensionalWeightKg()).isEqualByComparingTo("4.003");
|
||||
assertThat(row.getVolumeCbm()).isEqualByComparingTo("0.0667");
|
||||
assertThat(row.getPalletCount()).isEqualTo(2);
|
||||
LoadingCompletedOutboxService.projectSettledRow(row);
|
||||
assertThat(row.getProductQuantity()).isEqualTo(14);
|
||||
assertThat(row.getGrossWeightKg()).isEqualByComparingTo("6.667");
|
||||
assertThat(jdbc.queryForObject("SELECT package_count FROM fl_loading_plan_item",Integer.class)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test void previewUsesHalfUpFallbackAndKeepsUnknownMetricsNull() {
|
||||
seed(1,11,3,2,"PARTIAL","客户A");
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET units_per_carton=NULL,product_quantity=10,gross_weight_kg=NULL,net_weight_kg=NULL,dimensional_weight_kg=NULL,volume_cbm=NULL");
|
||||
Row row = snapshots.previewRows(90).get(0);
|
||||
LoadingCompletedOutboxService.projectSettledRow(row);
|
||||
assertThat(row.getProductQuantity()).isEqualTo(7);
|
||||
assertThat(row.getGrossWeightKg()).isNull();
|
||||
assertThat(row.getNetWeightKg()).isNull();
|
||||
assertThat(row.getDimensionalWeightKg()).isNull();
|
||||
assertThat(row.getVolumeCbm()).isNull();
|
||||
}
|
||||
|
||||
@Test void finalPublicationRejectsMixedUnsettledItemsInsteadOfPublishingOnlyFullOnes() {
|
||||
seed(1,11,3,3,"PICKED","客户A");
|
||||
seed(2,22,3,2,"PARTIAL","客户B");
|
||||
assertThat(snapshots.rows(90)).hasSize(1);
|
||||
assertThatThrownBy(() -> service.publish(90)).hasMessageContaining("尚未完成结算");
|
||||
verifyNoInteractions(labels,outbox);
|
||||
}
|
||||
|
||||
@Test void actualSettlementSqlProducesExactlyThePredictedMetricsAndEnablesFinalEvent() throws Exception {
|
||||
seed(1,11,3,2,"PARTIAL","客户A");
|
||||
Row expected = snapshots.previewRows(90).get(0);
|
||||
LoadingCompletedOutboxService.projectSettledRow(expected);
|
||||
String sql = String.join("\n", com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.class
|
||||
.getMethod("convergePartialPlanItem",long.class,long.class,String.class)
|
||||
.getAnnotation(org.apache.ibatis.annotations.Update.class).value());
|
||||
String update = ("MERGE INTO fl_loading_plan_item plan_item USING fl_loading_pick_item pick_item "
|
||||
+ "ON pick_item.loading_plan_item_id=plan_item.id WHEN MATCHED AND (" + sql.substring(sql.indexOf("WHERE ") + 6)
|
||||
+ ") THEN UPDATE SET " + sql.substring(sql.indexOf("SET ") + 4,sql.indexOf("WHERE ")))
|
||||
.replace("#{operatorId}","9").replace("#{operatorName}","'TEST'").replace("#{planItemId}","1");
|
||||
assertThat(jdbc.update(update)).isEqualTo(1);
|
||||
session.clearCache();
|
||||
Row settled = snapshots.rows(90).get(0);
|
||||
assertThat(settled).usingRecursiveComparison().isEqualTo(expected);
|
||||
service.publish(90);
|
||||
var published = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.entity.InboundOutboxEntity.class);
|
||||
verify(outbox).insert(published.capture());
|
||||
var json = new ObjectMapper().readTree(published.getValue().getPayloadJson());
|
||||
assertThat(json.at("/payload/orders/0/packageCount").asInt()).isEqualTo(2);
|
||||
assertThat(json.at("/payload/orders/0/productQuantity").asInt()).isEqualTo(14);
|
||||
assertThat(json.at("/payload/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("6.667");
|
||||
}
|
||||
|
||||
@Test void palletPreviewUsesActualScopeAndMatchesSettledFallbackWithoutWritingLabels() {
|
||||
seed(1,11,3,2,"PARTIAL","客户A");
|
||||
seed(2,22,3,3,"SKIPPED","客户B");
|
||||
jdbc.update("UPDATE fl_loading_pick_item SET picked_package_count=0 WHERE loading_plan_item_id=2");
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1");
|
||||
jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED')");
|
||||
var labelMapper = session.getMapper(LoadingPalletLabelMapper.class);
|
||||
var before = jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id");
|
||||
var projected = labelMapper.previewSources(90);
|
||||
assertThat(projected).singleElement().satisfies(row -> {
|
||||
assertThat(row.getItemId()).isEqualTo(1L);
|
||||
assertThat(row.getPalletCount()).isEqualTo(2);
|
||||
});
|
||||
assertThat(jdbc.queryForList("SELECT * FROM fl_loading_plan_item ORDER BY id")).isEqualTo(before);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero();
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET package_count=2,pallet_count=0 WHERE id=1");
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=2");
|
||||
jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1");
|
||||
session.clearCache();
|
||||
assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected);
|
||||
}
|
||||
|
||||
@Test void fallbackPalletPredictionPreservesOriginalInboundDenominatorAfterPartialSettlement() {
|
||||
seed(1,11,3,1,"PARTIAL","客户A");
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET pallet_count=NULL WHERE id=1");
|
||||
jdbc.update("INSERT INTO fl_inbound_weight_result VALUES(1,11,1,0,'CONFIRMED'),(2,11,1,0,'CONFIRMED'),(3,11,1,0,'CONFIRMED')");
|
||||
var labelMapper = session.getMapper(LoadingPalletLabelMapper.class);
|
||||
var projected = labelMapper.previewSources(90);
|
||||
assertThat(projected).singleElement().satisfies(row -> assertThat(row.getPalletCount()).isEqualTo(1));
|
||||
// Production settlement changes package_count and resets pallet_count;
|
||||
// the original declaration total_package_count remains three.
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET package_count=1,pallet_count=0 WHERE id=1");
|
||||
jdbc.update("UPDATE fl_inbound_order SET pallet_count=0 WHERE id=1");
|
||||
session.clearCache();
|
||||
assertThat(labelMapper.lockSources(90)).usingRecursiveComparison().isEqualTo(projected);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_pallet_label",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
private void seed(long id,long account,int planned,int picked,String status,String customer) {
|
||||
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,business_type,fulfillment_type,destination_type,destination_warehouse_id,destination_code,destination_name,deleted) VALUES(?,?,?,'FL-TEST','IN-TEST','FBA','STANDARD','FBA',7,'ABE8','TEST',0)",id,account,id);
|
||||
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,inbound_detail_id,customer_name,sku_code,product_name_zh,package_count,product_quantity,units_per_carton,pallet_count,gross_weight_kg,net_weight_kg,dimensional_weight_kg,volume_cbm,deleted,unit_type) VALUES(?,90,?,?,? ,?,'SKU-TEST','TEST',?,21,7,2,10.001,8.002,6.004,0.1001,0,'PACKAGE')",id,account,id,id,customer,planned);
|
||||
jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,92,?,?,?)",id,id,status,picked,planned);
|
||||
jdbc.update("UPDATE fl_loading_plan_item SET inbound_code='IN-TEST',destination_warehouse_id=7,destination_warehouse_code='ABE8',destination_warehouse_name='TEST',load_sequence=? WHERE id=?",id,id);
|
||||
jdbc.update("UPDATE fl_inbound_order SET total_package_count=?,pallet_count=2 WHERE id=?",planned,id);
|
||||
}
|
||||
|
||||
private static CabinetLoadingEvidenceEntity photo(String type) {
|
||||
var value = new CabinetLoadingEvidenceEntity(); value.setEvidenceType(type); value.setFileId(1L);
|
||||
value.setFileName(type+".png"); value.setStatus("ACTIVE"); value.setActiveFlag(1); return value;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,29 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LoadingPalletLabelServiceTest {
|
||||
@Test
|
||||
void previewChecksDestinationAndExistingCodesWithoutLocksIdsOrWrites() {
|
||||
var mapper = mock(LoadingPalletLabelMapper.class);
|
||||
var service = new LoadingPalletLabelService();
|
||||
var warehouses = mock(WarehouseMasterDataClient.class);
|
||||
ReflectionTestUtils.setField(service,"mapper",mapper);
|
||||
ReflectionTestUtils.setField(service,"warehouseClient",warehouses);
|
||||
ReflectionTestUtils.setField(service,"internalToken","test-token");
|
||||
when(mapper.previewSources(10L)).thenReturn(List.of(source(3,"LAX9",1,"STANDARD")));
|
||||
when(warehouses.active("test-token",0L,101L)).thenReturn(warehouse("LAX9","154"));
|
||||
var preview = service.preview(10L,20L);
|
||||
assertThat(preview).singleElement().satisfies(row -> {
|
||||
assertThat(row.getPalletCode()).isEqualTo("D0002-154-001");
|
||||
assertThat(row.getLabelId()).isNull();
|
||||
});
|
||||
org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).lockSources(org.mockito.ArgumentMatchers.anyLong());
|
||||
org.mockito.Mockito.verify(mapper,org.mockito.Mockito.never()).insert(any());
|
||||
var wrong = new LoadingPalletLabelMapper.LabelRow(); wrong.setItemId(99L); wrong.setPalletCode("D0002-154-001");
|
||||
when(mapper.labels(10L)).thenReturn(List.of(wrong));
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.preview(10L,20L))
|
||||
.hasMessageContaining("当前装柜明细不一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() {
|
||||
var service = org.mockito.Mockito.spy(new LoadingPalletLabelService());
|
||||
|
||||
Reference in New Issue
Block a user