release: 晋级阿里云 TEST 后端并修复出车统计 UTC 合并回归 #40
+11
-1
@@ -6,6 +6,15 @@ import java.util.List;
|
||||
/** 每趟只取首次真实发车快照;取货动作记录不能冒充仓库装车。 */
|
||||
@Mapper
|
||||
public interface TripOperationsQueryMapper {
|
||||
/** 发车边界和完成时间显式按 UTC DATETIME 读写,避免默认 Instant 处理器引入连接时区偏移。 */
|
||||
@ConstructorArgs({
|
||||
@Arg(column = "trip_no", javaType = String.class),
|
||||
@Arg(column = "status", javaType = String.class),
|
||||
@Arg(column = "completed_at", javaType = java.time.Instant.class, typeHandler = TripOperationsUtcInstantTypeHandler.class),
|
||||
@Arg(column = "snapshot", javaType = String.class),
|
||||
@Arg(column = "measured", javaType = String.class),
|
||||
@Arg(column = "transport_facts", javaType = String.class)
|
||||
})
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT t.trip_no,t.status,t.completed_at,
|
||||
@@ -27,7 +36,8 @@ public interface TripOperationsQueryMapper {
|
||||
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
|
||||
ELSE NULL END AS transport_facts
|
||||
FROM ll_fleet_trip t
|
||||
WHERE t.departed_at >= #{q.from} AND t.departed_at < #{q.to}
|
||||
WHERE t.departed_at >= #{q.from,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||
AND t.departed_at < #{q.to,typeHandler=com.baiyue.lastleg.fleet.reporting.TripOperationsUtcInstantTypeHandler}
|
||||
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
|
||||
<if test='q.status != null and q.status != ""'>AND t.status=#{q.status}</if>
|
||||
ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** 出车统计的 DATETIME 保存 UTC 本地时间,读写均不使用连接或进程的默认时区。 */
|
||||
public class TripOperationsUtcInstantTypeHandler extends BaseTypeHandler<Instant> {
|
||||
/** 查询边界按 UTC 墙钟时间绑定,与发车事实的入库语义一致。 */
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement statement, int index, Instant value, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
statement.setObject(index, LocalDateTime.ofInstant(value, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
/** 按列名读取 UTC 完成时间,保留尚未完成的空值。 */
|
||||
@Override
|
||||
public Instant getNullableResult(ResultSet results, String column) throws SQLException {
|
||||
return toInstant(results.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 构造器按列序读取时沿用相同的 UTC 时间语义。 */
|
||||
@Override
|
||||
public Instant getNullableResult(ResultSet results, int column) throws SQLException {
|
||||
return toInstant(results.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 存储过程读取同样将 DATETIME 解释为 UTC,不补默认时间。 */
|
||||
@Override
|
||||
public Instant getNullableResult(CallableStatement statement, int column) throws SQLException {
|
||||
return toInstant(statement.getObject(column, LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 将数据库墙钟值还原为 UTC 时刻。 */
|
||||
private Instant toInstant(LocalDateTime value) {
|
||||
return value == null ? null : value.toInstant(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "真实出车与自有外接运营统计",
|
||||
"version": "1.0.0",
|
||||
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||
"description": "仅实际departed_at非空的派送、取货及本土卡车车次;分别读取DEPART、PICKUP_DEPART/PICKUP_PICKED或LOCAL_TRUCK_DEPART不可变发车事实,取货历史须带实际发车时间,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class TripOperationsRepositoryTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TripOperationsRepository repository;
|
||||
|
||||
/** 使用真实 Mapper 覆盖灰度历史与测试版运输查询的合并。 */
|
||||
@BeforeEach void setUp() throws Exception {
|
||||
var source = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),trip_purpose VARCHAR(16),status VARCHAR(32),warehouse_id BIGINT,departed_at TIMESTAMP,completed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_operations_record(trip_no VARCHAR(64),record_version BIGINT,details_json CLOB)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(trip_id BIGINT,task_type VARCHAR(32),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(trip_no VARCHAR(64),status VARCHAR(32),actual_packages INT)");
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(TripOperationsQueryMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(source);
|
||||
factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TripOperationsQueryMapper.class);
|
||||
repository = new TripOperationsRepository(mapper);
|
||||
}
|
||||
|
||||
/** 释放本例数据库,避免定向回归累积隔离状态。 */
|
||||
@AfterEach void closeDatabase() {
|
||||
if (jdbc != null) jdbc.execute("SHUTDOWN");
|
||||
}
|
||||
|
||||
/** H2 替身读取真实嵌套 JSON,缺失路径保持 SQL NULL。 */
|
||||
public static String jsonExtract(String document, String path) throws Exception {
|
||||
if (document == null) return null;
|
||||
var value = new ObjectMapper().readTree(document).at(path.substring(1).replace('.', '/'));
|
||||
return value.isMissingNode() ? null : value.toString();
|
||||
}
|
||||
|
||||
/** 保留字符串及 JSON null 的解码语义。 */
|
||||
public static String jsonUnquote(String document) throws Exception {
|
||||
return document == null ? null : new ObjectMapper().readTree(document).asText();
|
||||
}
|
||||
|
||||
/** 三类车次各取真实发车历史,取货初始空记录不能覆盖实提事实。 */
|
||||
@Test void pickupAndLocalTruckFactsUseTheirOwnHistoryWithoutBreakingDeliveryReport() throws Exception {
|
||||
trip(1, "TR-A", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,3,'DEPART',?)", TripOperationsServiceTest.DEPARTURE);
|
||||
trip(2, "PICKUP-A", "PICKUP", "COMPLETED", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,1,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(2,3,'PICKUP_PICKED',?)", transportSnapshot("PICKUP-A", "PICKUP"));
|
||||
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES('PICKUP-A','PICKED',5),('PICKUP-A','ARRIVED',2),('PICKUP-A','PENDING',99)");
|
||||
trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(3,2,'LOCAL_TRUCK_DEPART',?)", transportSnapshot("TRUCK-A", "LOCAL_TRUCK"));
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(3,'LOCAL_TRUCK',9)");
|
||||
|
||||
var result = service().report(query(null, null), false);
|
||||
|
||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TRUCK-A", "PICKUP-A", "TR-A");
|
||||
assertThat(result.summary().trips()).isEqualTo(3);
|
||||
assertThat(result.summary().physicalPallets()).isEqualTo(1);
|
||||
assertThat(result.summary().ownPackages()).isEqualTo(27);
|
||||
assertThat(result.summary().externalPackages()).isEqualTo(4);
|
||||
assertThat(result.page().records().get(0).physicalPallets()).isNull();
|
||||
assertThat(result.page().records().get(1).physicalPallets()).isNull();
|
||||
}
|
||||
|
||||
/** 非 UTC 连接仍按 UTC 含起点、不含终点筛选,完成时间也不能发生偏移。 */
|
||||
@Test void deliveryFiltersKeepInclusiveStartExclusiveEndAndWarehouseStatus() {
|
||||
trip(1, "START", "DELIVERY", "COMPLETED", 1, "2026-10-01 00:00:00");
|
||||
trip(2, "END", "DELIVERY", "COMPLETED", 1, "2026-10-03 00:00:00");
|
||||
trip(3, "BEFORE", "DELIVERY", "COMPLETED", 1, "2026-09-30 23:59:59");
|
||||
trip(4, "OTHER-WAREHOUSE", "DELIVERY", "COMPLETED", 2, "2026-10-02 00:00:00");
|
||||
trip(5, "IN-TRANSIT", "DELIVERY", "IN_TRANSIT", 1, "2026-10-02 00:00:00");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET completed_at='2026-10-02 14:15:00' WHERE id=1");
|
||||
assertThat(repository.departures(query(1L, "COMPLETED")))
|
||||
.extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START");
|
||||
assertThat(repository.departures(query(1L, "COMPLETED")).get(0).completedAt())
|
||||
.isEqualTo(Instant.parse("2026-10-02T14:15:00Z"));
|
||||
}
|
||||
|
||||
/** 三类车次缺少匹配历史都应显式报错,不能静默丢弃。 */
|
||||
@Test void missingMatchingHistoryStillFailsForEveryTripPurpose() {
|
||||
for (String purpose : java.util.List.of("DELIVERY", "PICKUP", "LOCAL_TRUCK")) {
|
||||
jdbc.update("DELETE FROM ll_fleet_trip_history");
|
||||
jdbc.update("DELETE FROM ll_fleet_trip");
|
||||
trip(1, "MISSING", purpose, "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,'PICKUP_PICKED','{}')");
|
||||
assertThatThrownBy(() -> service().report(query(null, null), false))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING");
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留显式取货发车及旧版 DEPART 动作,未登记实提箱数不补预报。 */
|
||||
@Test void pickupDepartureAndLegacyDepartActionsRemainReadable() throws Exception {
|
||||
for (String action : java.util.List.of("PICKUP_DEPART", "DEPART")) {
|
||||
jdbc.update("DELETE FROM ll_fleet_trip_history");
|
||||
jdbc.update("DELETE FROM ll_fleet_trip");
|
||||
trip(1, "PICKUP-A", "PICKUP", "IN_TRANSIT", 1, "2026-10-01 00:00:00");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,1,?,?)", action, transportSnapshot("PICKUP-A", "PICKUP"));
|
||||
var result = service().report(query(null, null), false);
|
||||
assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("PICKUP-A");
|
||||
assertThat(result.summary().ownPackages()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/** 运输发车快照保留行程和实际时间,不伪造仓库装车托数。 */
|
||||
private String transportSnapshot(String number, String purpose) throws Exception {
|
||||
var json = new ObjectMapper();
|
||||
var snapshot = json.createObjectNode();
|
||||
var trip = snapshot.putObject("trip");
|
||||
trip.put("tripNo", number);
|
||||
trip.put("tripPurpose", purpose);
|
||||
trip.put("departedAt", "2026-10-01T00:00:00Z");
|
||||
var stops = trip.putArray("stops");
|
||||
stops.addObject().put("name", "起点");
|
||||
stops.addObject().put("name", "终点");
|
||||
return json.writeValueAsString(snapshot);
|
||||
}
|
||||
|
||||
/** 创建本例隔离库中的发车事实。 */
|
||||
private void trip(long id, String number, String purpose, String status, long warehouse, String departed) {
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(?,?,?,?,?,?,NULL)", id, number, purpose, status, warehouse, departed);
|
||||
}
|
||||
|
||||
/** 构造范围固定的只读查询。 */
|
||||
private TripOperationsModels.Query query(Long warehouse, String status) {
|
||||
return new TripOperationsModels.Query(1, 20, Instant.parse("2026-10-01T00:00:00Z"),
|
||||
Instant.parse("2026-10-03T00:00:00Z"), warehouse, null, null, status);
|
||||
}
|
||||
|
||||
/** 关闭成本查询,聚焦发车事实的持久化和投影。 */
|
||||
private TripOperationsService service() {
|
||||
return new TripOperationsService(repository, new ObjectMapper().registerModule(new JavaTimeModule()), mock(FleetTripCostService.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user