From 9af1eba876cc865e825d6c92c0ea3314bf0951ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 17:59:59 +0800 Subject: [PATCH] fix(lastleg): scope departure reporting to delivery trips --- .../reporting/TripOperationsRepository.java | 3 +- .../openapi/fleet-operations-v1.yaml | 2 +- .../TripOperationsRepositoryTest.java | 74 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java index 762fc6041..20b5b4e96 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java @@ -17,12 +17,13 @@ public class TripOperationsRepository { ORDER BY h.record_version LIMIT 1) AS snapshot_json, (SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no ORDER BY r.record_version DESC LIMIT 1) AS measured_json - FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ? + FROM ll_fleet_trip t WHERE t.trip_purpose='DELIVERY' AND t.departed_at >= ? AND t.departed_at < ? """); var args = new ArrayList(); args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC)); if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); } if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); } + // 派送统计只消费 DEPART 实载快照;取货与本土卡车使用各自的执行历史,不能混入此投影。 // Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot. sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001"); return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"), diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index 2bdb4cd4c..b7181be32 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -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)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" + "description": "仅DELIVERY派送且实际departed_at非空车次;PICKUP取货和LOCAL_TRUCK本土卡车使用不同执行事实,不纳入本派送统计;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。" }, "security": [ { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepositoryTest.java new file mode 100644 index 000000000..c7954bdc7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepositoryTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.fleet.reporting; + +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.Test; +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; + +class TripOperationsRepositoryTest { + private JdbcTemplate jdbc; + private TripOperationsRepository repository; + + @BeforeEach void setUp() { + jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:trip-report-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + 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)"); + repository = new TripOperationsRepository(jdbc); + } + + @Test void pickupAndLocalTruckFactsDoNotBreakDeliveryReport() { + 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,3,'PICKUP_PICKED','{}'),(2,4,'PICKUP_ARRIVED','{}')"); + trip(3, "TRUCK-A", "LOCAL_TRUCK", "IN_TRANSIT", 1, "2026-10-02 00:00:00"); + + var result = service().report(query(null, null), false); + + assertThat(result.page().records()).extracting(TripOperationsModels.Row::tripNo).containsExactly("TR-A"); + assertThat(result.summary().trips()).isEqualTo(1); + assertThat(result.summary().physicalPallets()).isEqualTo(1); + assertThat(result.summary().ownPackages()).isEqualTo(11); + assertThat(result.summary().externalPackages()).isEqualTo(4); + } + + @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"); + assertThat(repository.departures(query(1L, "COMPLETED"))) + .extracting(TripOperationsRepository.Departure::tripNo).containsExactly("START"); + } + + @Test void missingDeliveryHistoryStillFailsInsteadOfSilentlyDroppingTheTrip() { + trip(1, "MISSING", "DELIVERY", "IN_TRANSIT", 1, "2026-10-01 00:00:00"); + assertThatThrownBy(() -> service().report(query(null, null), false)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("不可变历史").hasMessageContaining("MISSING"); + } + + 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)); + } +}