fix(firstleg): 修正船期货量顺延与订柜建议

This commit is contained in:
2026-10-27 16:56:20 +08:00
parent ed6a173b86
commit ab457e022d
7 changed files with 703 additions and 108 deletions
@@ -0,0 +1,363 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class SailingCalendarDisplayMapperTest {
private JdbcTemplate jdbc;
private SqlSession session;
private SailingCalendarDisplayMapper mapper;
/** 使用隔离内存库执行实际 Mapper,覆盖顺延、离仓扣减和去重规则。 */
@BeforeEach
void setUp() {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fl_sailing(id BIGINT, sailing_no VARCHAR, warehouse_id BIGINT, channel_code_snapshot VARCHAR, status VARCHAR, etd_at TIMESTAMP, warehouse_cutoff_at TIMESTAMP, departure_port_code VARCHAR, destination_port_code VARCHAR, vessel_name VARCHAR, voyage_no VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT, tenant_id BIGINT, deleted INT, measure_review_status VARCHAR, process_status VARCHAR, warehouse_id BIGINT, channel_code VARCHAR, split_status VARCHAR, actual_volume_cbm DECIMAL(18,6), inbounded_at TIMESTAMP, measure_completed_at TIMESTAMP, created_at TIMESTAMP, total_package_count INT)");
jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT, container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT, loading_plan_id BIGINT, account_id BIGINT, inbound_id BIGINT, deleted INT, volume_cbm DECIMAL(18,6), inbound_detail_id BIGINT DEFAULT 1, package_count INT DEFAULT 0, unit_type VARCHAR DEFAULT 'PACKAGE')");
jdbc.execute("CREATE TABLE fl_inbound_measurement(id BIGINT, tenant_id BIGINT, inbound_id BIGINT, status VARCHAR)");
jdbc.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT, tenant_id BIGINT, measurement_id BIGINT, inbound_detail_id BIGINT, package_count INT, total_volume_cbm DECIMAL(18,4))");
jdbc.execute("CREATE TABLE fl_outbound_dispatch(tenant_id BIGINT, inbound_id BIGINT, container_booking_id BIGINT, status VARCHAR, confirmed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT,status VARCHAR,warehouse_id BIGINT,channel_code VARCHAR,departure_port_code VARCHAR,destination_port_code VARCHAR,vessel_name VARCHAR,voyage_no VARCHAR,estimated_sailing_at TIMESTAMP)");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_requirement(id BIGINT,ocean_booking_id BIGINT,quantity INT,capacity_cbm DECIMAL(18,6))");
jdbc.execute("CREATE TABLE fl_ocean_booking_container_allocation(ocean_booking_id BIGINT,requirement_id BIGINT,container_booking_id BIGINT)");
jdbc.execute("CREATE TABLE fl_sailing_booking_link(sailing_id BIGINT,ocean_booking_id BIGINT,link_status VARCHAR)");
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(SailingCalendarDisplayMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession();
mapper = session.getMapper(SailingCalendarDisplayMapper.class);
sailing(1, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
sailing(2, "2026-11-04T10:00:00", "2026-11-03T18:00:00", "OPEN");
}
/** 释放本测试的会话和内存表。 */
@AfterEach
void tearDown() {
session.close();
jdbc.execute("DROP ALL OBJECTS");
}
/** 测量前全票占位无论高估、低估或箱数调整,都只计入审核后的实测货量。 */
@ParameterizedTest
@CsvSource({"100,6000", "40,6000", "100,10000"})
void recomputesDeclaredAllocationFromApprovedMeasurement(String declaredVolume, int allocatedBoxes) {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_loading_plan_item SET volume_cbm=?,package_count=?", new java.math.BigDecimal(declaredVolume), allocatedBoxes);
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
assertThat(advice(2, "2026-10-27T12:00:00").recommendedContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
}
/** 不同规格按自己的实测单箱体积计算,避免用整票均值错误分配部分货量。 */
@Test
void computesPartialAllocationsUsingEachMeasuredSpecification() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3000 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=1000,total_volume_cbm=10 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(11,7,10,2,2000,50)");
allocate(1, 100, "5");
allocate(1, 200, "5");
jdbc.update("UPDATE fl_loading_plan_item SET inbound_detail_id=2 WHERE id=200");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("17.5");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,200,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("5");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("42.5");
}
/** 三等分等循环小数的分摊总量仍等于实测,不产生额外余量补柜建议。 */
@Test
void conservesMeasuredVolumeAcrossRoundedAllocations() {
booking(2, 3, "68");
inbound(1, "1", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET total_package_count=3 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET package_count=3 WHERE id=10");
allocate(1, 100, "0.01");
allocate(1, 200, "0.01");
allocate(1, 300, "0.01");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("1");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("0.6667");
}
/** 只读取最新已审核批次,同明细多箱规累加,排除待审核批次及其他客户。 */
@Test
void usesOnlyTheLatestApprovedMeasurementForTheAllocationOwner() {
booking(2, 1, "68");
inbound(1, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "60");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=100 WHERE id=10");
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(20,7,1,'APPROVED'),(30,7,1,'SUBMITTED'),(40,8,1,'APPROVED')");
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(20,7,20,1,2000,10),(21,7,20,1,4000,50),(30,7,30,1,6000,999),(40,8,40,1,6000,999)");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 一件代发整托只使用主行的物理托盘体积,附加 SKU 行不能再次增加货量。 */
@Test
void keepsSealedPalletPhysicalVolumeWithoutDuplicatingSkuLines() {
booking(2, 1, "68");
inbound(1, "20", "LOADING", "NOT_SPLIT");
allocate(1, 100, "20");
jdbc.update("UPDATE fl_loading_plan_item SET unit_type='SEALED_PALLET'");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,inbound_detail_id,package_count,unit_type) VALUES(200,100,7,1,0,0,2,2000,'SEALED_PALLET')");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("20");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 老货不按原收货窗口丢失,截止前只计入最近一班,截止后跨月顺延。 */
@Test
void rollsUnshippedBacklogForwardOnlyOnceAndAcrossMonths() {
inbound(1, "113.74", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(1, "2026-10-27T18:00:00").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-10-27T18:00:00").catchableCbm()).isZero();
assertThat(summary(1, "2026-10-27T18:00:01").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T18:00:01").catchableCbm()).isEqualByComparingTo("113.74");
assertThat(summary(2, "2026-11-02T12:00:00").catchableCbm()).isEqualByComparingTo("113.74");
}
/** 取消、截止和过期航次不承接货量;同一时间的两班也只选择一班。 */
@Test
void skipsIneligibleSailingsAndBreaksTiesDeterministically() {
inbound(1, "12", "WAIT_SPLIT", "NOT_SPLIT");
sailing(3, "2026-10-26T10:00:00", "2026-10-29T18:00:00", "OPEN");
sailing(4, "2026-10-27T15:00:00", "2026-10-27T14:00:00", "CANCELLED");
sailing(5, "2026-10-27T16:00:00", "2026-10-27T14:00:00", "CLOSED");
sailing(6, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("12");
for (long id : new long[]{2, 3, 4, 5, 6}) {
assertThat(summary(id, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
}
/** 实际离仓才扣减,部分离仓只扣该柜明细,撤销离仓后重新纳入。 */
@Test
void subtractsOnlyConfirmedDeparturesAndRestoresReversedVolume() {
inbound(1, "100", "LOADED", "NOT_SPLIT");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100),(20,200)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000),(2,20,7,1,0,60,6000),(3,10,8,1,0,999,99900),(4,10,7,1,1,999,99900)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00'),(7,1,200,'READY_FOR_DISPATCH',NULL),(8,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH' WHERE tenant_id=7 AND container_booking_id=100");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_at='2026-10-27 11:00:00' WHERE tenant_id=7");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
}
/** 拣货结算后的源单和余量已各自保存实测体积,保留未离仓部分并排除失效数据。 */
@Test
void includesLoadedSplitSourceAndRemainderWithoutCountingInvalidReceipts() {
inbound(1, "40", "LOADED", "FULLY_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
inbound(3, "100", "WAIT_SPLIT", "PARTIALLY_SPLIT");
inbound(4, "999", "DELIVERED", "NOT_SPLIT");
inbound(5, "999", "CANCELLED", "NOT_SPLIT");
inbound(6, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(7, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(8, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(9, "999", "WAIT_SPLIT", "NOT_SPLIT");
inbound(10, "999", "WAIT_SPLIT", "NOT_SPLIT");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99 WHERE id=6");
jdbc.update("UPDATE fl_inbound_order SET channel_code='OTHER' WHERE id=7");
jdbc.update("UPDATE fl_inbound_order SET deleted=1 WHERE id=8");
jdbc.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=9");
jdbc.update("UPDATE fl_inbound_order SET inbounded_at='2026-10-28 10:00:00' WHERE id=10");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_loading_plan VALUES(10,100)");
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(1,10,7,1,0,40,4000)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-26 10:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("60");
}
/** 无可赶上的船期时不把货量展示在已截止航次,新建有效船期后可再次承接。 */
@Test
void waitsForANewEligibleSailingWithoutLosingTheBacklog() {
inbound(1, "10", "WAIT_SPLIT", "NOT_SPLIT");
assertThat(summary(2, "2026-11-04T12:00:00").catchableCbm()).isZero();
sailing(3, "2026-11-11T10:00:00", "2026-11-10T18:00:00", "OPEN");
assertThat(summary(3, "2026-11-04T12:00:00").catchableCbm()).isEqualByComparingTo("10");
}
/** 已离仓柜的总需求仍可查看,但其容量不能继续抵扣仓内余货。 */
@Test
void excludesDepartedContainerCapacityFromRemainingCargoAdvice() {
booking(1, 1, "68");
inbound(1, "68", "LOADED", "NOT_SPLIT");
inbound(2, "60", "WAIT_SPLIT", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(1);
assertThat(booked.capacityCbm()).isEqualByComparingTo("68");
assertThat(booked.availableCapacityCbm()).isZero();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
jdbc.update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH'");
session.clearCache();
assertThat(mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00")).availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 同柜多票只扣一次柜容量,多个柜型需求按各自容量独立扣减。 */
@Test
void subtractsEachDepartedContainerOnceFromItsOwnRequirement() {
booking(1, 2, "68");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1002,1000,1,58)");
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,100),(1000,1001,200),(1000,1002,300)");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(8,2,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00'),(7,3,200,'READY_FOR_DISPATCH',NULL),(7,4,300,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
var booked = mapper.bookingSummary(1, LocalDateTime.parse("2026-10-27T00:00:00"));
assertThat(booked.containerCount()).isEqualTo(3);
assertThat(booked.capacityCbm()).isEqualByComparingTo("194");
assertThat(booked.availableCapacityCbm()).isEqualByComparingTo("68");
}
/** 已确认后班的货保留在后班,只有未安排余量进入前班,避免重复订柜。 */
@Test
void preservesConfirmedSailingAndOnlyRollsTheUnallocatedRemainder() {
booking(2, 1, "68");
inbound(1, "100", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isEqualTo(1);
assertThat(advice(2, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
// 全票已排柜时,前班不再产生任何补柜建议。
jdbc.update("UPDATE fl_inbound_order SET actual_volume_cbm=68,total_package_count=6800 WHERE id=1");
jdbc.update("UPDATE fl_inbound_measurement_item SET total_volume_cbm=68,package_count=6800 WHERE measurement_id=10");
session.clearCache();
assertThat(advice(1, "2026-10-27T12:00:00").pendingContainerCount()).isZero();
jdbc.update("UPDATE fl_sailing SET status='CLOSED' WHERE id=2");
session.clearCache();
assertThat(summary(2, "2026-11-03T19:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 原安排取消后只顺延未离仓部分;已有实际离仓事实不会再次进入待发池。 */
@Test
void rollsInvalidatedBookingsWithoutRestoringDepartedCargo() {
booking(2, 1, "68");
inbound(1, "100", "LOADED", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_sailing SET status='CANCELLED' WHERE id=2");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("100");
jdbc.update("INSERT INTO fl_outbound_dispatch VALUES(7,1,100,'WAITING_FOR_LOADING','2026-10-27 11:00:00')");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("32");
}
/** 显式关联优先于运输快照匹配,未关联时同名船期也只确定一班。 */
@Test
void resolvesOneBookingTargetAndHonorsAnExplicitLink() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_ocean_booking SET vessel_name='VESSEL',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'");
sailing(3, "2026-10-28T10:00:00", "2026-10-27T18:00:00", "OPEN");
jdbc.update("UPDATE fl_sailing SET voyage_no='VOYAGE-1' WHERE id=3");
session.clearCache();
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
assertThat(summary(3, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(mapper.bookingSummary(3, LocalDateTime.parse("2026-10-27T00:00:00")).containerCount()).isZero();
}
/** 人工跨仓、跨渠道排柜已有正式订舱时,应跟随真实安排展示。 */
@Test
void preservesCrossWarehouseAndChannelAssignments() {
booking(2, 1, "68");
inbound(1, "68", "LOADING", "NOT_SPLIT");
allocate(1, 100, "68");
jdbc.update("UPDATE fl_inbound_order SET warehouse_id=99,channel_code='OTHER'");
assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero();
assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68");
}
/** 查看后月或筛选后班时仍返回前班截仓节点,仓库和渠道范围继续生效。 */
@Test
void findsRefreshBoundaryOutsideTheDisplayedMonthAndKeyword() {
var query = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 8L, null, "MATSON", "OTHER", "OTHER", "SL-2", "CLOSED");
var now = LocalDateTime.parse("2026-10-27T12:00:00");
assertThat(mapper.nextCutoff(query, now, now.toLocalDate().atStartOfDay())).isEqualTo("2026-10-27T18:00:00");
assertThat(mapper.nextCutoff(query, LocalDateTime.parse("2026-10-27T18:00:01"), now.toLocalDate().atStartOfDay())).isEqualTo("2026-11-03T18:00:00");
var otherWarehouse = new com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery("2026-11", 99L, null, "MATSON", null, null, null, null);
assertThat(mapper.nextCutoff(otherWarehouse, now, now.toLocalDate().atStartOfDay())).isNull();
}
/** 建立一条已放舱订舱及唯一显式船期关联。 */
private void booking(long sailingId, int quantity, String capacity) {
jdbc.update("INSERT INTO fl_ocean_booking VALUES(1000,'RELEASED',8,'MATSON','CNNGB','USLGB',NULL,NULL,NULL)");
jdbc.update("INSERT INTO fl_ocean_booking_container_requirement VALUES(1001,1000,?,?)", quantity, new java.math.BigDecimal(capacity));
jdbc.update("INSERT INTO fl_sailing_booking_link VALUES(?,1000,'ACTIVE')", sailingId);
}
/** 按真实柜次关联订舱需求,并记录对应入库体积。 */
private void allocate(long inboundId, long containerId, String volume) {
jdbc.update("INSERT INTO fl_loading_plan VALUES(?,?)", containerId, containerId);
jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,inbound_id,deleted,volume_cbm,package_count) VALUES(?,?,7,?,0,?,?)", containerId, containerId, inboundId, new java.math.BigDecimal(volume), new java.math.BigDecimal(volume).movePointRight(2).intValueExact());
jdbc.update("INSERT INTO fl_ocean_booking_container_allocation VALUES(1000,1001,?)", containerId);
}
/** 将实际 Mapper 的货量与容量投影送入生产订柜算法,验证组合场景。 */
private com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.BookingAdvice advice(long id, String time) {
var now = LocalDateTime.parse(time);
var repository = org.mockito.Mockito.mock(com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository.class);
var sailing = new com.baiyue.firstleg.sailingcalendar.entity.SailingEntity();
sailing.setId(id);
org.mockito.Mockito.when(repository.sailing(id)).thenReturn(sailing);
org.mockito.Mockito.when(repository.receiptSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(summary(id, time));
org.mockito.Mockito.when(repository.bookingSummary(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(mapper.bookingSummary(id, now.toLocalDate().atStartOfDay()));
var service = new com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "repository", repository);
return service.bookingAdvice(id);
}
/** 构造同仓同渠道的船期,只使用公开业务编号排序。 */
private void sailing(long id, String etd, String cutoff, String status) {
jdbc.update("INSERT INTO fl_sailing VALUES(?, ?, 8, 'MATSON', ?, ?, ?, 'CNNGB', 'USLGB', 'VESSEL', ?)", id, "SL-" + id, status,
LocalDateTime.parse(etd), LocalDateTime.parse(cutoff), "VOYAGE-" + id);
}
/** 构造上月遗留的审核实测货量。 */
private void inbound(long id, String volume, String status, String splitStatus) {
var measuredVolume = new java.math.BigDecimal(volume);
int boxes = measuredVolume.movePointRight(2).intValueExact();
jdbc.update("INSERT INTO fl_inbound_order VALUES(?,7,0,'APPROVED',?,8,'MATSON',?,?,'2026-09-01 10:00:00',NULL,NULL,?)",
id, status, splitStatus, measuredVolume, boxes);
jdbc.update("INSERT INTO fl_inbound_measurement VALUES(?,7,?,'APPROVED')", id * 10, id);
jdbc.update("INSERT INTO fl_inbound_measurement_item VALUES(?,7,?,1,?,?)", id * 10, id * 10, boxes, measuredVolume);
}
/** 用上海业务时间调用生产 SQL,读取目标船期自己的截止时间。 */
private SailingCalendarDisplayMapper.ReceiptSummary summary(long id, String time) {
LocalDateTime now = LocalDateTime.parse(time);
LocalDateTime cutoff = jdbc.queryForObject("SELECT warehouse_cutoff_at FROM fl_sailing WHERE id=?", LocalDateTime.class, id);
return mapper.receiptSummary(id, 8L, "MATSON", cutoff, now, now.toLocalDate().atStartOfDay());
}
}
@@ -2,16 +2,24 @@ package com.baiyue.firstleg.sailingcalendar.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
@@ -22,6 +30,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.mock;
class SailingCalendarServiceTest {
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
@@ -31,10 +41,13 @@ class SailingCalendarServiceTest {
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
@BeforeEach
/** 固定上海跨日边界,避免测试依赖运行机日期和时区。 */
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "clock", Clock.fixed(Instant.parse("2026-10-26T16:00:00Z"), ZoneId.of("UTC")));
}
/** 复制幂等回放保留原有船期,只重新读取当前展示汇总。 */
@Test
void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", copiedDeparture.minusDays(7));
@@ -45,8 +58,8 @@ class SailingCalendarServiceTest {
when(repository.sailing(2L)).thenReturn(existing);
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
when(repository.histories(2L)).thenReturn(List.of(history));
when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null);
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenReturn(null);
var result = service.copy(1L, 7L, "admin",
new CopyRequest(existing.getEtdAt(), copiedDeparture.minusDays(3)));
@@ -81,6 +94,117 @@ class SailingCalendarServiceTest {
verify(repository, never()).insert(any(SailingEntity.class));
}
/** 各柜型独立向上取整,正数不足一柜也应给出一柜建议。 */
@ParameterizedTest
@CsvSource({"0,0,0,0", "0.001,1,1,1", "58,1,1,1", "58.001,2,1,1", "68,2,1,1",
"68.001,2,2,1", "86,2,2,1", "86.001,2,2,2", "113.74,2,2,2"})
void roundsAllReceivedVolumeUpForEachContainerType(String volume, int gp, int hq, int largeHq) {
SailingEntity sailing = sailing(1L, "SL-ADVICE", "MANUAL", "TEST", copiedDeparture);
when(repository.sailing(1L)).thenReturn(sailing);
// 复现截图:全部实收都被标为晚到,不能因此得到零柜。
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(BigDecimal.ZERO, new BigDecimal(volume), 1));
var advice = service.bookingAdvice(1L);
var summary = service.volumeSummary(1L).summary();
assertThat(advice.actualReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.catchableTotalCbm()).isZero();
assertThat(advice.lateReceivedCbm()).isEqualByComparingTo(volume);
assertThat(advice.recommendedContainerCount()).isEqualTo(hq);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(summary.recommendedContainerCount()).isEqualTo(hq);
assertThat(summary.pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.recommendedContainerCount()).containsExactly(gp, hq, largeHq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 待补柜扣除已订实际容量,而不是简单扣除已订柜数,超额容量不产生负数。 */
@ParameterizedTest
@CsvSource({"1,58,2,2,1", "2,126,1,1,1", "2,136,0,0,0", "3,204,0,0,0"})
void subtractsBookedCapacityBeforeRoundingTheShortage(int booked, String capacity, int gp, int hq, int largeHq) {
when(repository.sailing(1L)).thenReturn(sailing(1L, "SL-BOOKED", "MANUAL", "TEST", copiedDeparture));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("100"), new BigDecimal("36"), 1));
when(repository.bookingSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.BookingSummary(booked, new BigDecimal(capacity), new BigDecimal(capacity)));
var advice = service.bookingAdvice(1L);
assertThat(advice.recommendedContainerCount()).isEqualTo(2);
assertThat(advice.bookedContainerCount()).isEqualTo(booked);
assertThat(advice.pendingContainerCount()).isEqualTo(hq);
assertThat(service.volumeSummary(1L).summary().pendingContainerCount()).isEqualTo(hq);
assertThat(advice.scenarios()).extracting(s -> s.pendingContainerCount()).containsExactly(gp, hq, largeHq);
}
/** 上海今日零点前过期,今日整天及未来月份仍可参与列表和汇总。 */
@ParameterizedTest
@CsvSource({"2026-10,2026-10-27,2026-11-01", "2026-11,2026-11-01,2026-12-01"})
void limitsCalendarAndSummaryToTheUpcomingWindow(String month, LocalDate from, LocalDate to) {
SailingQuery query = new SailingQuery(month, null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-TODAY", "MANUAL", "TEST", from.atStartOfDay());
when(repository.calendar(query, from.atStartOfDay(), to.atStartOfDay())).thenReturn(List.of(first));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class)))
.thenReturn(new SailingCalendarDisplayMapper.ReceiptSummary(new BigDecimal("113.74"), BigDecimal.ZERO, 0));
var result = service.calendar(query);
verify(repository).calendar(query, from.atStartOfDay(), to.atStartOfDay());
assertThat(result.records()).extracting(r -> r.etdAt()).containsExactly(from.atStartOfDay());
assertThat(result.summary().sailingCount()).isEqualTo(1);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("113.74");
assertThat(result.summary().recommendedContainerCount()).isEqualTo(2);
}
/** 过去月份不查询历史货量,也不把历史船期混入汇总。 */
@Test
void returnsAnEmptyCalendarForAnExpiredMonth() {
var result = service.calendar(new SailingQuery("2026-09", null, null, null, null, null, null, null));
assertThat(result.records()).isEmpty();
assertThat(result.summary().sailingCount()).isZero();
assertThat(result.summary().recommendedContainerCount()).isZero();
verifyNoInteractions(repository);
}
/** 刷新时间包括隐藏月份的前班截仓节点,并以上海零点为最长等待边界。 */
@ParameterizedTest
@CsvSource({"2026-10-27T00:00:00,2026-10-27T00:00:00.001", "2026-10-27T18:00:00,2026-10-27T18:00:00.001", "2026-11-03T18:00:00,2026-10-28T00:00:00"})
void schedulesRefreshUsingCrossMonthCutoff(String cutoff, String expectedRefresh) {
SailingQuery query = new SailingQuery("2026-11", null, null, null, null, null, "后班", null);
when(repository.nextCutoff(query, LocalDateTime.parse("2026-10-27T00:00:00")))
.thenReturn(LocalDateTime.parse(cutoff));
assertThat(service.calendar(query).nextRefreshAt()).isEqualTo(LocalDateTime.parse(expectedRefresh));
}
/** 即使系统时钟跨过截仓瞬间,同一次月历的所有船期也必须共享同一投影时刻。 */
@Test
void sharesOneSnapshotTimeAcrossCalendarRows() {
Clock movingClock = mock(Clock.class);
when(movingClock.withZone(any(ZoneId.class))).thenReturn(movingClock);
when(movingClock.getZone()).thenReturn(ZoneId.of("Asia/Shanghai"));
when(movingClock.instant()).thenReturn(Instant.parse("2026-10-27T10:00:00Z"), Instant.parse("2026-10-27T10:00:01Z"));
ReflectionTestUtils.setField(service, "clock", movingClock);
SailingQuery query = new SailingQuery("2026-10", null, null, null, null, null, null, null);
SailingEntity first = sailing(1L, "SL-FIRST", "MANUAL", "TEST-1", LocalDateTime.parse("2026-10-28T10:00:00"));
SailingEntity second = sailing(2L, "SL-SECOND", "MANUAL", "TEST-2", LocalDateTime.parse("2026-10-30T10:00:00"));
LocalDateTime cutoff = LocalDateTime.parse("2026-10-27T18:00:00");
when(repository.calendar(any(SailingQuery.class), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(first, second));
when(repository.receiptSummary(any(SailingEntity.class), any(LocalDateTime.class))).thenAnswer(call -> {
SailingEntity sailing = call.getArgument(0);
LocalDateTime asOf = call.getArgument(1);
boolean counted = asOf.isAfter(cutoff) ? sailing.getId() == 2L : sailing.getId() == 1L;
return new SailingCalendarDisplayMapper.ReceiptSummary(counted ? BigDecimal.TEN : BigDecimal.ZERO, BigDecimal.ZERO, 0);
});
var result = service.calendar(query);
assertThat(result.summary().actualReceivedCbm()).isEqualByComparingTo("10");
assertThat(result.records()).extracting(row -> row.volumeSummary().snapshotAt()).containsExactly(cutoff, cutoff);
verify(movingClock).instant();
}
/** 构造具备稳定业务编号的船期样本。 */
private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) {
SailingEntity value = new SailingEntity();
value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo);