diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java index 3d8479253..e626982e1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapper.java @@ -13,6 +13,7 @@ import java.time.LocalDateTime; public interface SailingCalendarDisplayMapper { // 显式关联优先;无显式关联才按运输信息匹配。每条订舱只对应一个有效船期, // 已确认货物允许保留在已截仓但尚未过期的船期,不能因为截仓就再次建议订柜。 + // 跨表文本按字段完整长度转换为连接字符集,避免表间排序规则冲突,同时保留完整运输快照。 String BOOKING_TARGETS = """ SELECT b.id AS booking_id, ( SELECT s.id FROM fl_sailing s @@ -24,11 +25,13 @@ public interface SailingCalendarDisplayMapper { OR ( NOT EXISTS (SELECT 1 FROM fl_sailing_booking_link link WHERE link.ocean_booking_id = b.id AND link.link_status = 'ACTIVE') - AND b.warehouse_id = s.warehouse_id AND b.channel_code = s.channel_code_snapshot - AND b.departure_port_code = s.departure_port_code - AND b.destination_port_code = s.destination_port_code + AND b.warehouse_id = s.warehouse_id + AND CAST(b.channel_code AS CHAR(64)) = CAST(s.channel_code_snapshot AS CHAR(64)) + AND CAST(b.departure_port_code AS CHAR(32)) = CAST(s.departure_port_code AS CHAR(32)) + AND CAST(b.destination_port_code AS CHAR(32)) = CAST(s.destination_port_code AS CHAR(32)) AND b.vessel_name IS NOT NULL AND b.voyage_no IS NOT NULL - AND b.vessel_name = s.vessel_name AND b.voyage_no = s.voyage_no + AND CAST(b.vessel_name AS CHAR(128)) = CAST(s.vessel_name AS CHAR(128)) + AND CAST(b.voyage_no AS CHAR(64)) = CAST(s.voyage_no AS CHAR(64)) AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(s.etd_at)) ) ) @@ -99,7 +102,7 @@ public interface SailingCalendarDisplayMapper { """) BookingSummary bookingSummary(@Param("sailingId") long sailingId, @Param("todayStart") LocalDateTime todayStart); - /** 按审核实测重算普通箱分配,再区分已离仓、有效订舱占用和未安排余量,保持体积守恒。 */ + /** 按审核实测重算分配,跨表渠道统一文本排序规则后归入唯一船期,保持待发体积守恒。 */ @Select(""" SELECT COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm, COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm, @@ -109,7 +112,7 @@ public interface SailingCalendarDisplayMapper { COALESCE(allocated.current_cbm, 0) + CASE WHEN #{sailingId} = ( SELECT next_sailing.id FROM fl_sailing next_sailing WHERE next_sailing.warehouse_id = o.warehouse_id - AND next_sailing.channel_code_snapshot = o.channel_code + AND CAST(next_sailing.channel_code_snapshot AS CHAR(64)) = CAST(o.channel_code AS CHAR(64)) AND next_sailing.status = 'OPEN' AND next_sailing.etd_at >= #{todayStart} AND next_sailing.warehouse_cutoff_at >= #{asOf} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapperTest.java index 3409cf9cc..c36bd38b2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingCalendarDisplayMapperTest.java @@ -288,6 +288,31 @@ class SailingCalendarDisplayMapperTest { assertThat(mapper.bookingSummary(3, LocalDateTime.parse("2026-10-27T00:00:00")).containerCount()).isZero(); } + /** 跨表文本转换必须保留完整编码与中文船名,不能将同前缀的运输信息视为同一船期。 */ + @ParameterizedTest + @CsvSource({"channel_code,MATSON-OTHER", "departure_port_code,CNSHA", "destination_port_code,USLAX", + "vessel_name,远洋轮乙", "voyage_no,VOYAGE-OTHER"}) + void matchesCompleteTransportSnapshotsAfterTextConversion(String column, String differentValue) { + booking(1, 1, "68"); + jdbc.update("UPDATE fl_sailing_booking_link SET link_status='REVOKED'"); + jdbc.update("UPDATE fl_sailing SET vessel_name='远洋轮甲' WHERE id=1"); + jdbc.update("UPDATE fl_ocean_booking SET vessel_name='远洋轮甲',voyage_no='VOYAGE-1',estimated_sailing_at='2026-10-28 10:00:00'"); + var today = LocalDateTime.parse("2026-10-27T00:00:00"); + assertThat(mapper.bookingSummary(1, today).containerCount()).isEqualTo(1); + jdbc.update("UPDATE fl_ocean_booking SET " + column + "=?", differentValue); + session.clearCache(); + assertThat(mapper.bookingSummary(1, today).containerCount()).isZero(); + } + + /** 顺延按完整渠道编码匹配,排除只有前缀相同的更早船期。 */ + @Test + void rollsCargoOnlyToTheCompleteChannelCode() { + inbound(1, "68", "WAIT_SPLIT", "NOT_SPLIT"); + jdbc.update("UPDATE fl_sailing SET channel_code_snapshot='MATSON-OTHER' WHERE id=1"); + assertThat(summary(1, "2026-10-27T12:00:00").catchableCbm()).isZero(); + assertThat(summary(2, "2026-10-27T12:00:00").catchableCbm()).isEqualByComparingTo("68"); + } + /** 人工跨仓、跨渠道排柜已有正式订舱时,应跟随真实安排展示。 */ @Test void preservesCrossWarehouseAndChannelAssignments() {