chore: commit mileage updates and accumulated project artifacts
This commit is contained in:
@@ -81,7 +81,7 @@
|
||||
|
||||
<section class="section" id="daily-hydrogen"><div class="section-head"><div><h2>车辆单日用氢量</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</div><p>查询车辆单日用氢量,单位 kg。NORMAL + OK + PRELIMINARY 仅供初步监控;正式报表要求 FINAL 并核对证据与版本。两个日统计接口没有共同快照,区间缺失或不同不能直接计算百公里氢耗。</p></div><a class="anchor" href="#daily-hydrogen">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>车牌数组;省略或 [] 时查询全部授权车辆</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"plateNumber"</span>: <span class="string">"浙F06618F"</span>,<br> <span class="key">"hydrogenConsumptionKg"</span>: <span class="number">12.315</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期或车牌格式不正确</li><li>401:appKey 无效、停用或过期</li><li>403:指定车辆未授权</li></ul></div></div></section>
|
||||
|
||||
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>日里程按同来源相邻日累计差计算;缺报日沿用累计值、日里程补 0(CARRIED_FORWARD),跨缺报期增量计入恢复日。首次基线、来源切换或累计回退返回 DATA_ANOMALY,日里程为 null。GPS 估算不参与本接口。</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
|
||||
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>日里程优先采用中台已选中且质量通过的终端日统计及其同来源基线,来源切换不单独触发异常;缺报日沿用累计值、日里程补 0(CARRIED_FORWARD),跨缺报期增量计入恢复日。没有已验证日统计时才按相邻日累计差计算;无法验证的首次基线、来源切换或累计回退返回 DATA_ANOMALY,日里程为 null。GPS 估算不参与本接口。</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
|
||||
|
||||
<section class="section" id="mileage-range"><div class="section-head"><div><h2>车辆区间日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</div><p>按车辆、日期分页返回区间日里程,最长查询区间为 366 天。</p></div><a class="anchor" href="#mileage-range">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>startDate / endDate</td><td class="required">是</td><td>日期区间,yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>车牌数组,最多 5000 辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序</td></tr><tr><td>pageSize / cursor</td><td class="optional">否</td><td>分页大小及下一页游标</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"date"</span>: <span class="string">"2026-08-06"</span>,<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span><br> }],<br> <span class="key">"nextCursor"</span>: <span class="string">null</span><br>}</pre><ul class="errors"><li>400:区间超限或游标与原参数不一致</li><li>403:授权未完整覆盖查询区间</li></ul></div></div></section>
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ paths:
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
protocolPriority 传入时,逐车按数组顺序选择截至查询日已有有效累计读数的第一个协议;高优先级协议缺报时沿用其历史累计值,不切换累计基准,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程按同来源相邻自然日累计读数之差计算,缺报期间增量计入恢复上报日;GPS 估算不参与本接口。首次基线、来源切换、累计回退返回 DATA_ANOMALY、dailyMileageKm=null 和 dataQuality。
|
||||
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程优先采用中台已选中且质量通过的终端日统计及其同来源基线,跨缺报期增量计入恢复上报日;来源切换本身不会否定已验证的日统计。无已验证日统计时按同来源累计差计算,首次基线、来源切换或累计回退无法验证时返回 DATA_ANOMALY、dailyMileageKm=null 和 dataQuality。GPS 估算不参与本接口。
|
||||
当日缺报时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用同协议历史读数,dataQuality=CARRIED_FORWARD。
|
||||
operationId: queryDailyMileage
|
||||
security:
|
||||
@@ -141,7 +141,7 @@ paths:
|
||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK。日里程与累计值使用同一终端来源;日里程为相邻自然日累计差,GPS 估算不参与本接口。单日、区间和分页共用相同计算规则。
|
||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK。日里程与累计值使用同一终端来源;日里程优先采用中台已验证的日统计及其同来源基线,来源切换不单独触发异常;缺少已验证日统计时才使用相邻自然日累计差。GPS 估算不参与本接口。单日、区间和分页共用相同计算规则。
|
||||
operationId: queryDailyMileageRange
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReconciledMileageRange uses terminal odometers, including one preceding
|
||||
// observation per protocol. The same function serves single days and every
|
||||
// ReconciledMileageRange uses verified platform daily mileage when available,
|
||||
// with terminal odometers and one preceding observation per protocol as fallback. The same function serves single days and every
|
||||
// range page: neither the requested start date nor page size changes a result.
|
||||
func (r *MySQLRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
|
||||
if len(vins) == 0 {
|
||||
@@ -42,9 +42,16 @@ func (r *MySQLRepository) ReconciledMileageRange(ctx context.Context, vins []str
|
||||
DATE_FORMAT(s.latest_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),
|
||||
DATE_FORMAT(s.updated_at,'%Y-%m-%dT%H:%i:%s+08:00'),
|
||||
COALESCE(DATE_FORMAT(s.first_event_time,'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
CASE WHEN s.quality_status='INVALID_DELTA' THEN COALESCE(s.quality_reason,'INVALID_DELTA') ELSE '' END
|
||||
CASE WHEN s.quality_status='INVALID_DELTA' THEN COALESCE(NULLIF(s.quality_reason,''),'INVALID_DELTA') ELSE '' END,
|
||||
CASE WHEN s.quality_status='OK' AND s.is_selected=1 AND m.vin IS NOT NULL
|
||||
AND m.daily_mileage_km>=0 AND m.daily_mileage_km<=s.latest_total_mileage_km
|
||||
THEN 1 ELSE 0 END
|
||||
FROM wanted w JOIN vehicle_daily_mileage_source s
|
||||
ON s.vin=w.vin AND s.protocol=w.protocol AND s.stat_date=w.stat_date
|
||||
LEFT JOIN vehicle_daily_mileage m
|
||||
ON m.vin=s.vin AND m.protocol=s.protocol AND m.stat_date=s.stat_date
|
||||
AND m.daily_mileage_km=s.daily_mileage_km
|
||||
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km)=s.latest_total_mileage_km
|
||||
WHERE ` + eligible("s") + `
|
||||
ORDER BY s.stat_date,s.vin,s.protocol,s.is_selected DESC,
|
||||
CASE WHEN s.quality_status='OK' THEN 0 ELSE 1 END,s.latest_event_time DESC,s.sample_count DESC,s.source_key`
|
||||
@@ -72,7 +79,7 @@ func (r *MySQLRepository) ReconciledMileageRange(ctx context.Context, vins []str
|
||||
seen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var v DailyMileage
|
||||
if err := rows.Scan(&v.VIN, &v.Date, &v.Protocol, &v.SourceKey, &v.MileageKm, &v.TotalMileageKm, &v.DataTime, &v.UpdatedAt, &v.StatisticsStartTime, &v.DataQuality); err != nil {
|
||||
if err := rows.Scan(&v.VIN, &v.Date, &v.Protocol, &v.SourceKey, &v.MileageKm, &v.TotalMileageKm, &v.DataTime, &v.UpdatedAt, &v.StatisticsStartTime, &v.DataQuality, &v.PlatformMileage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := v.VIN + "|" + v.Date + "|" + v.Protocol
|
||||
@@ -152,6 +159,10 @@ func reconcileMileage(points []DailyMileage, vins []string, start, end string, p
|
||||
}
|
||||
} else if current.DataQuality != "" {
|
||||
result.MileageKm = 0
|
||||
} else if current.PlatformMileage {
|
||||
// The daily projection already used this source's own baseline.
|
||||
// Recomputing against yesterday's selected source can mix odometers
|
||||
// or discard valid recovery mileage after a source switch.
|
||||
} else if !hasPrevious {
|
||||
result.DataQuality = "NO_PREVIOUS_BASELINE"
|
||||
} else if current.Protocol != previous.Protocol || current.SourceKey != previous.SourceKey {
|
||||
|
||||
@@ -81,3 +81,52 @@ func TestReconciliationSourceReplacementAcrossPageBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationPreservesPlatformMileageOnSourceSwitch(t *testing.T) {
|
||||
const vin = "LB9A32A29R0LS1065"
|
||||
points := []DailyMileage{
|
||||
{VIN: vin, Date: "2026-09-16", Protocol: "GB32960", SourceKey: "a", MileageKm: 38.9, TotalMileageKm: 42006.3, DataTime: "2026-09-16T16:36:30+08:00", PlatformMileage: true},
|
||||
{VIN: vin, Date: "2026-09-17", Protocol: "GB32960", SourceKey: "b", MileageKm: 308.2, TotalMileageKm: 42077, StatisticsStartTime: "2026-09-11T11:01:12+08:00", DataTime: "2026-09-17T21:55:02+08:00", PlatformMileage: true},
|
||||
}
|
||||
all, err := reconcileMileage(points, []string{vin}, "2026-09-16", "2026-09-18", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, day := range []string{"2026-09-16", "2026-09-17", "2026-09-18"} {
|
||||
one, err := reconcileMileage(points, []string{vin}, day, day, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(one[dailyMileageKey(vin, day)], all[dailyMileageKey(vin, day)]) {
|
||||
t.Fatalf("page changes %s", day)
|
||||
}
|
||||
}
|
||||
v := all[dailyMileageKey(vin, "2026-09-17")]
|
||||
single := MileageResult{Date: "2026-09-17"}
|
||||
fillMileageResult(&single, v, v.MileageKm)
|
||||
var ranged MileageRangeResult
|
||||
fillMileageRangeResult(&ranged, v, v.MileageKm)
|
||||
if single.Status != StatusNormal || single.DailyMileageKm == nil || *single.DailyMileageKm != 308.2 || single.DataQuality != nil || single.StatisticsStartTime == nil || *single.StatisticsStartTime != points[1].StatisticsStartTime {
|
||||
t.Fatalf("single=%+v", single)
|
||||
}
|
||||
if ranged.Status != StatusNormal || ranged.DailyMileageKm == nil || *ranged.DailyMileageKm != 308.2 || ranged.DataQuality != nil {
|
||||
t.Fatalf("range=%+v", ranged)
|
||||
}
|
||||
carried := all[dailyMileageKey(vin, "2026-09-18")]
|
||||
if carried.MileageKm != 0 || carried.DataQuality != "CARRIED_FORWARD" || carried.StatisticsStartTime != "" {
|
||||
t.Fatalf("carried=%+v", carried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationPlatformMileageDoesNotMaskInvalidSource(t *testing.T) {
|
||||
got, err := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-17", Protocol: "GB32960", MileageKm: 308.2, TotalMileageKm: 42077, DataTime: "2026-09-17T21:55:02+08:00", DataQuality: mileageTotalRollbackQuality, PlatformMileage: true}}, []string{"v"}, "2026-09-17", "2026-09-17", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := got[dailyMileageKey("v", "2026-09-17")]
|
||||
var result MileageResult
|
||||
fillMileageResult(&result, v, v.MileageKm)
|
||||
if result.Status != StatusDataAnomaly || result.DailyMileageKm != nil {
|
||||
t.Fatalf("%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,8 @@ type DailyHydrogen struct {
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
// PlatformMileage confirms this source matches the selected, quality-passed daily projection.
|
||||
PlatformMileage bool
|
||||
SourceKey string
|
||||
DataQuality string
|
||||
StatisticsStartTime string
|
||||
|
||||
@@ -314,3 +314,27 @@ func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciledMileageUsesVerifiedPlatformProjection(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("WITH wanted AS.*s.is_selected=1.*LEFT JOIN vehicle_daily_mileage m.*m.daily_mileage_km=s.daily_mileage_km").
|
||||
WithArgs("v", "GB32960", "2026-09-17", "2026-09-17", "v", "GB32960", "2026-09-17").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "source", "daily", "total", "data_time", "updated_at", "start", "quality", "platform"}).
|
||||
AddRow("v", "2026-09-16", "GB32960", "a", 38.9, 42006.3, "2026-09-16T16:36:30+08:00", "2026-09-18T01:35:16+08:00", "2026-09-15T10:42:23+08:00", "", true).
|
||||
AddRow("v", "2026-09-17", "GB32960", "b", 308.2, 42077, "2026-09-17T21:55:02+08:00", "2026-09-18T01:35:20+08:00", "2026-09-11T11:01:12+08:00", "", true))
|
||||
got, err := NewMySQLRepository(db).ReconciledMileageRange(context.Background(), []string{"v"}, "2026-09-17", "2026-09-17", []string{"GB32960"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := got[dailyMileageKey("v", "2026-09-17")]
|
||||
if !v.PlatformMileage || v.MileageKm != 308.2 || v.DataQuality != "" || v.TotalMileageKm != 42077 {
|
||||
t.Fatalf("%+v", v)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user