功能:扩展开放平台氢耗溯源与合作站数据

This commit is contained in:
lingniu
2026-09-02 14:05:34 +08:00
parent 8759668d39
commit c6875fbb98
33 changed files with 5692 additions and 475 deletions
@@ -51,7 +51,7 @@ var gb32960PositionMappings = func() []MetricValueMapping {
var gb32960FieldReferences = map[string]gb32960FieldReference{
"gb32960.vehicle.vehicle_status": {Label: "车辆状态", Description: "车辆运行、停止等状态编码。", ValueMappings: enumMappings("1", "启动", "车辆处于可行驶启动状态。", "2", "熄火", "车辆处于熄火状态。", "3", "其他", "车辆处于其他状态。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.charge_status": {Label: "充电状态", Description: "车辆充电状态编码。", ValueMappings: enumMappings("1", "停车充电", "车辆停车充电。", "2", "行驶充电", "车辆行驶充电。", "3", "未充电", "车辆未充电。", "4", "充电完成", "车辆充电完成。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃料电池等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃料电池", "燃料电池驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.running_mode": {Label: "运行模式", Description: "纯电、混合动力、燃等运行模式编码。", ValueMappings: enumMappings("1", "纯电", "纯电驱动模式。", "2", "混合动力", "混合动力驱动模式。", "3", "燃", "燃驱动模式。", "254", "异常", "协议异常值。", "255", "无效", "协议无效值。")},
"gb32960.vehicle.speed_kmh": {Label: "车速", Unit: "km/h", Description: "车辆当前速度。"},
"gb32960.vehicle.total_mileage_km": {Label: "累计里程", Unit: "km", Description: "车辆累计行驶里程。"},
"gb32960.vehicle.total_voltage_v": {Label: "总电压", Unit: "V", Description: "动力系统总电压。"},
@@ -48,6 +48,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/hydrogen-evidence", h.handleHydrogenDailyEvidence)
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
@@ -862,6 +863,11 @@ func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Req
h.write(w, r, data, err)
}
func (h *Handler) handleHydrogenDailyEvidence(w http.ResponseWriter, r *http.Request) {
data, err := h.service.HydrogenDailyEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date"))
h.write(w, r, data, err)
}
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
defer r.Body.Close()
var query MileageQuery
@@ -0,0 +1,40 @@
package platform
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEvents(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
columns := []string{
"vin", "plate", "date", "source_endpoint", "raw_consumption_kg", "battery_soc_delta_pct",
"battery_discharge_kwh", "battery_equivalent_kg", "soc_balanced_consumption_kg",
"mixed_mileage_km", "pure_electric_mileage_km", "consumption_kg_per_100km", "soc_balanced_kg_per_100km",
"sample_count", "refuel_count", "charge_count", "valid_segment_count", "invalid_segment_count",
"quality_status", "quality_reason", "algorithm_version", "parameter_json", "evidence_json", "calculated_at",
}
parameterJSON := `{"batteryCapacityKWh":21.04,"hydrogenEnergyKWhPerKg":16,"algorithmVersion":"PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2"}`
evidenceJSON := `[{"index":1,"type":"MIXED","startEventId":"event-start","endEventId":"event-end","rawHydrogenConsumptionKg":3.1,"sampleCount":120,"qualityStatus":"OK","qualityReason":""}]`
mock.ExpectQuery("SELECT h.vin").WithArgs("LTEST000000000001", "2026-08-26").WillReturnRows(sqlmock.NewRows(columns).AddRow(
"LTEST000000000001", "粤A12345", "2026-08-26", "factory-a", 3.1, -2.0, 0.421, 0.026, 3.126,
56.2, 32.5, 5.516, 5.562, 120, 1, 1, 1, 0, "OK", "", "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
[]byte(parameterJSON), []byte(evidenceJSON), "2026-08-27 00:05:00.000",
))
result, found, err := (&ProductionStore{db: db}).HydrogenDailyEvidence(context.Background(), "LTEST000000000001", "2026-08-26")
if err != nil || !found {
t.Fatalf("found=%v err=%v", found, err)
}
if result.Parameters.BatteryCapacityKWh != 21.04 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
t.Fatalf("result=%#v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
@@ -1881,10 +1881,83 @@ type DailyMileageRow struct {
PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"`
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
HydrogenSOCBalancedKg *float64 `json:"hydrogenSocBalancedKg,omitempty"`
HydrogenSOCBalancedKgPer100Km *float64 `json:"hydrogenSocBalancedKgPer100Km,omitempty"`
HydrogenEvidenceAvailable bool `json:"hydrogenEvidenceAvailable,omitempty"`
HydrogenQualityStatus string `json:"hydrogenQualityStatus,omitempty"`
HydrogenAlgorithmVersion string `json:"hydrogenAlgorithmVersion,omitempty"`
Source string `json:"source"`
AnomalySeverity string `json:"anomalySeverity,omitempty"`
}
type HydrogenCalculationParameterEvidence struct {
BatteryCapacityKWh float64 `json:"batteryCapacityKWh"`
HydrogenEnergyKWhPerKg float64 `json:"hydrogenEnergyKWhPerKg"`
PowerOnDelaySeconds int `json:"powerOnDelaySeconds"`
PowerOffLeadSeconds int `json:"powerOffLeadSeconds"`
RefuelRiseMpa float64 `json:"refuelRiseMpa"`
RefuelSustainSeconds int `json:"refuelSustainSeconds"`
PureElectricDropMpa float64 `json:"pureElectricDropMpa"`
PureElectricWindowSeconds int `json:"pureElectricWindowSeconds"`
AlgorithmVersion string `json:"algorithmVersion"`
}
type HydrogenIntervalEvidenceRow struct {
Index int `json:"index"`
Type string `json:"type"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
StartEventID string `json:"startEventId"`
EndEventID string `json:"endEventId"`
Source string `json:"source"`
StartPressureMpa float64 `json:"startPressureMpa"`
EndPressureMpa float64 `json:"endPressureMpa"`
StartTemperatureC float64 `json:"startTemperatureC"`
EndTemperatureC float64 `json:"endTemperatureC"`
StartMassKg float64 `json:"startMassKg"`
EndMassKg float64 `json:"endMassKg"`
RawHydrogenConsumptionKg float64 `json:"rawHydrogenConsumptionKg"`
StartSOCPercent *float64 `json:"startSocPercent,omitempty"`
EndSOCPercent *float64 `json:"endSocPercent,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
StartMileageKm *float64 `json:"startMileageKm,omitempty"`
EndMileageKm *float64 `json:"endMileageKm,omitempty"`
MileageKm *float64 `json:"mileageKm,omitempty"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
}
type HydrogenDailyEvidence struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Date string `json:"date"`
Source string `json:"source"`
RawConsumptionKg float64 `json:"rawConsumptionKg"`
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
MixedMileageKm float64 `json:"mixedMileageKm"`
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
ChargeCount int `json:"chargeCount"`
ValidSegmentCount int `json:"validSegmentCount"`
InvalidSegmentCount int `json:"invalidSegmentCount"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
AlgorithmVersion string `json:"algorithmVersion"`
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
CalculatedAt string `json:"calculatedAt"`
}
// MileageQuery is the POST contract used by mileage statistics and daily
// mileage queries. Array fields keep large vehicle selections out of the URL
// and avoid ambiguity when clients submit thousands of VINs.
@@ -575,6 +575,12 @@ WHERE ` + strings.Join(where, " AND ")
dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
`MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` +
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` +
`MAX(CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END) AS hydrogen_evidence_available, ` +
`COALESCE(MAX(h.quality_status), '') AS hydrogen_quality_status, ` +
`COALESCE(MAX(h.algorithm_version), '') AS hydrogen_algorithm_version, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
Args: args,
@@ -591,7 +597,9 @@ WHERE ` + strings.Join(where, " AND ")
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
`h.soc_balanced_kg_per_100km, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
`COALESCE(h.quality_status, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL,
@@ -687,6 +695,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
dailyMileageExpression + ` AS daily_mileage_km, ` +
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
`CASE WHEN MAX(COALESCE(h.mixed_mileage_km,0))>0 THEN MAX(h.mixed_mileage_km) ELSE ` + pureHydrogenMileageExpression + ` END AS hydrogen_matched_mileage_km, ` +
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
latestMileageExpression + ` AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m
@@ -702,8 +711,9 @@ func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
}
@@ -711,8 +721,9 @@ func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
`COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_consumption_kg ELSE 0 END), 0), ` +
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN d.hydrogen_matched_mileage_km ELSE 0 END), 0), ` +
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
}
@@ -1026,6 +1026,89 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
const latestRealtimeProjectionTimeSQL = "COALESCE(s.received_at, s.event_time, s.updated_at)"
func buildLatestRealtimeFramesSQL(query RawFrameQuery) SQLQuery {
limit := query.Limit
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
limit = len(canonicalVehicleProtocols)
}
where := []string{"s.vin = ?"}
args := []any{strings.TrimSpace(query.VIN)}
if protocol := strings.ToUpper(strings.TrimSpace(query.Protocol)); protocol != "" {
where = append(where, "s.protocol = ?")
args = append(args, protocol)
}
if value := strings.TrimSpace(query.DateFrom); value != "" {
if parsed, ok := parseTrackRequestTime(value); ok {
where = append(where, latestRealtimeProjectionTimeSQL+" >= ?")
args = append(args, parsed)
}
}
if value := strings.TrimSpace(query.DateTo); value != "" {
if parsed, ok := parseTrackRequestTime(value); ok {
where = append(where, latestRealtimeProjectionTimeSQL+" <= ?")
args = append(args, parsed)
}
}
args = append(args, limit)
return SQLQuery{
Text: `SELECT COALESCE(NULLIF(s.event_id, ''), CONCAT('snapshot:', s.protocol, ':', s.vin)), ` +
`s.vin, COALESCE(s.plate, ''), s.protocol, ` +
`COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
`COALESCE(DATE_FORMAT(s.received_at, '%Y-%m-%dT%H:%i:%s.%f+08:00'), ''), ` +
`COALESCE(s.peer, ''), COALESCE(s.parsed_json, '') ` +
`FROM vehicle_realtime_snapshot s WHERE ` + strings.Join(where, " AND ") + ` ` +
`ORDER BY ` + latestRealtimeProjectionTimeSQL + ` DESC, s.protocol ASC LIMIT ?`,
Args: args,
}
}
// LatestRealtimeFrames returns one durable latest projection per protocol.
// Unlike Redis, these rows remain available after the online TTL expires.
func (s *ProductionStore) LatestRealtimeFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
built := buildLatestRealtimeFramesSQL(query)
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
if err != nil {
return Page[RawFrameRow]{}, err
}
defer rows.Close()
items := make([]RawFrameRow, 0, len(canonicalVehicleProtocols))
for rows.Next() {
var row RawFrameRow
var parsedJSON string
if err := rows.Scan(
&row.ID,
&row.VIN,
&row.Plate,
&row.Protocol,
&row.DeviceTime,
&row.ServerTime,
&row.SourceEndpoint,
&parsedJSON,
); err != nil {
return Page[RawFrameRow]{}, err
}
row.FrameType = "realtime_projection"
row.ParseStatus = "OK"
if query.IncludeFields || len(query.Fields) > 0 {
row.ParsedFields = parsedFieldsFromString(parsedJSON)
if len(query.Fields) > 0 {
row.ParsedFields = filterParsedFieldsMap(row.ParsedFields, query.Fields)
}
}
items = append(items, row)
}
if err := rows.Err(); err != nil {
return Page[RawFrameRow]{}, err
}
limit := query.Limit
if limit <= 0 || limit > len(canonicalVehicleProtocols) {
limit = len(canonicalVehicleProtocols)
}
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
}
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
built := buildMileageSummarySQL(query)
var summary MileageSummary
@@ -1054,15 +1137,31 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
items := make([]DailyMileageRow, 0)
for rows.Next() {
var row DailyMileageRow
var hydrogen sql.NullFloat64
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
var hydrogen, physicalRate, balanced, balancedRate sql.NullFloat64
var evidenceAvailable int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
&hydrogen, &physicalRate, &balanced, &balancedRate, &evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
return Page[DailyMileageRow]{}, err
}
if hydrogen.Valid {
consumption := hydrogen.Float64
row.HydrogenConsumptionKg = &consumption
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
if physicalRate.Valid {
value := physicalRate.Float64
row.HydrogenConsumptionKgPer100Km = &value
} else {
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.PureHydrogenMileageKm, 1)
}
}
if balanced.Valid {
value := balanced.Float64
row.HydrogenSOCBalancedKg = &value
}
if balancedRate.Valid {
value := balancedRate.Float64
row.HydrogenSOCBalancedKgPer100Km = &value
}
row.HydrogenEvidenceAvailable = evidenceAvailable == 1
items = append(items, row)
}
if err := rows.Err(); err != nil {
@@ -1075,11 +1174,66 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, bool, error) {
row := s.db.QueryRowContext(ctx, `SELECT h.vin,COALESCE(NULLIF(b.plate,''),''),DATE_FORMAT(h.stat_date,'%Y-%m-%d'),
h.source_endpoint,COALESCE(h.raw_consumption_kg,h.consumption_kg),h.battery_soc_delta_pct,
h.battery_discharge_kwh,h.battery_equivalent_kg,h.soc_balanced_consumption_kg,
h.mixed_mileage_km,h.pure_electric_mileage_km,h.consumption_kg_per_100km,h.soc_balanced_kg_per_100km,
h.sample_count,h.refuel_count,h.charge_count,h.valid_segment_count,h.invalid_segment_count,
h.quality_status,h.quality_reason,h.algorithm_version,h.parameter_json,h.evidence_json,
DATE_FORMAT(h.calculated_at,'%Y-%m-%d %H:%i:%s.%f')
FROM vehicle_open_daily_energy h
LEFT JOIN vehicle_identity_binding b ON b.vin=h.vin
WHERE h.vin=? AND h.stat_date=? AND h.energy_type='HYDROGEN'
LIMIT 1`, vin, date)
var result HydrogenDailyEvidence
var socDelta, discharge, equivalent, balanced, physicalRate, balancedRate sql.NullFloat64
var parameterJSON, evidenceJSON []byte
if err := row.Scan(
&result.VIN, &result.Plate, &result.Date, &result.Source, &result.RawConsumptionKg,
&socDelta, &discharge, &equivalent, &balanced, &result.MixedMileageKm, &result.PureElectricMileageKm,
&physicalRate, &balancedRate, &result.SampleCount, &result.RefuelCount, &result.ChargeCount,
&result.ValidSegmentCount, &result.InvalidSegmentCount, &result.QualityStatus, &result.QualityReason,
&result.AlgorithmVersion, &parameterJSON, &evidenceJSON, &result.CalculatedAt,
); err != nil {
if err == sql.ErrNoRows {
return HydrogenDailyEvidence{}, false, nil
}
return HydrogenDailyEvidence{}, false, err
}
result.BatterySOCDeltaPct = nullableFloatPointer(socDelta)
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
result.Intervals = []HydrogenIntervalEvidenceRow{}
if len(parameterJSON) > 0 {
if err := json.Unmarshal(parameterJSON, &result.Parameters); err != nil {
return HydrogenDailyEvidence{}, false, err
}
}
if len(evidenceJSON) > 0 {
if err := json.Unmarshal(evidenceJSON, &result.Intervals); err != nil {
return HydrogenDailyEvidence{}, false, err
}
}
return result, true, nil
}
func nullableFloatPointer(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
result := value.Float64
return &result
}
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的纯氢里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
}
summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
@@ -8,6 +8,8 @@ import (
"io"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func init() {
@@ -141,7 +143,7 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
}
}
func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) {
func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
if rate == nil || *rate < 3.77 || *rate > 3.78 {
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
@@ -200,6 +202,61 @@ func TestRawFramesReturnsEmptyPageWhenTDengineTableIsMissing(t *testing.T) {
}
}
func TestBuildLatestRealtimeFramesSQLUsesDurableVINProtocolProjection(t *testing.T) {
built := buildLatestRealtimeFramesSQL(RawFrameQuery{
VIN: "LMRKH9AC0R1004086",
Protocol: "yutong_mqtt",
DateFrom: "2026-07-01T00:00:00+08:00",
DateTo: "2026-08-25T18:00:00+08:00",
Limit: 10,
})
for _, expected := range []string{
"FROM vehicle_realtime_snapshot s",
"s.vin = ?",
"s.protocol = ?",
latestRealtimeProjectionTimeSQL + " >= ?",
latestRealtimeProjectionTimeSQL + " <= ?",
"ORDER BY " + latestRealtimeProjectionTimeSQL + " DESC",
} {
if !strings.Contains(built.Text, expected) {
t.Fatalf("latest projection SQL missing %q: %s", expected, built.Text)
}
}
if len(built.Args) != 5 || built.Args[0] != "LMRKH9AC0R1004086" || built.Args[1] != "YUTONG_MQTT" || built.Args[4] != len(canonicalVehicleProtocols) {
t.Fatalf("latest projection args = %#v", built.Args)
}
}
func TestLatestRealtimeFramesMapsPersistentProjectionToRawEvidence(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("(?s)FROM vehicle_realtime_snapshot s WHERE s\\.vin = \\?").
WithArgs("LMRKH9AC0R1004086", len(canonicalVehicleProtocols)).
WillReturnRows(sqlmock.NewRows([]string{"event_id", "vin", "plate", "protocol", "event_time", "received_at", "peer", "parsed_json"}).
AddRow("event-1", "LMRKH9AC0R1004086", "沪A03561F", "YUTONG_MQTT", "2026-08-25 16:49:56.451000", "2026-08-25 16:49:56.485000", "mqtt://yutong/4", `{"yutong_mqtt.data.speed_kmh":12.5}`))
store := &ProductionStore{db: db}
page, err := store.LatestRealtimeFrames(t.Context(), RawFrameQuery{VIN: "LMRKH9AC0R1004086", IncludeFields: true, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("latest projection page = %+v", page)
}
row := page.Items[0]
if row.ID != "event-1" || row.FrameType != "realtime_projection" || row.Protocol != "YUTONG_MQTT" || row.SourceEndpoint != "mqtt://yutong/4" {
t.Fatalf("latest projection row = %+v", row)
}
if got := row.ParsedFields["yutong_mqtt.data.speed_kmh"]; got != 12.5 {
t.Fatalf("projection parsed field = %#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOpsHealthUsesRedisOnlineKeyProbe(t *testing.T) {
db, err := sql.Open("ops_health_test", "")
if err != nil {
@@ -697,9 +697,9 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
"COUNT(DISTINCT d.vin)",
"SUM(d.daily_mileage_km)",
"SUM(d.pure_hydrogen_mileage_km)",
"COUNT(d.hydrogen_consumption_kg)",
"SUM(d.hydrogen_consumption_kg)",
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
"COUNT(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL AND d.hydrogen_matched_mileage_km > 0 THEN 1 END)",
"THEN d.hydrogen_consumption_kg ELSE 0 END",
"THEN d.hydrogen_matched_mileage_km ELSE 0 END",
} {
if !strings.Contains(summary.Text, want) {
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
@@ -710,7 +710,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
}
trend := buildMileageStatisticsTrendSQL(query)
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
!strings.Contains(trend.Text, "THEN d.hydrogen_consumption_kg ELSE 0 END") {
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
}
ranking := buildMileageStatisticsRankingSQL(query)
@@ -43,6 +43,10 @@ type Store interface {
OpsHealth(context.Context) (OpsHealth, error)
}
type HydrogenDailyEvidenceStore interface {
HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error)
}
type VehicleOverviewBatchStore interface {
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
}
@@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface {
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
}
// RealtimeSnapshotFrameStore exposes the durable per-protocol realtime
// projection. The projection is used as a time anchor for bounded TDengine
// lookups, so an offline vehicle can still resolve its latest evidence without
// scanning all retained RAW history.
type RealtimeSnapshotFrameStore interface {
LatestRealtimeFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
}
type RawFrameQuery struct {
Protocol string `json:"protocol"`
VIN string `json:"vin"`
@@ -1029,7 +1041,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
if err != nil {
return VehicleDetail{}, err
}
raw, err := s.RawFrames(ctx, rawQuery)
raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
if err != nil {
return VehicleDetail{}, err
}
@@ -1094,6 +1106,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
}, nil
}
const vehicleDetailRawProjectionLookback = 24 * time.Hour
type vehicleDetailRawPreviewResult struct {
anchor RawFrameRow
page Page[RawFrameRow]
}
// vehicleDetailRawPreview preserves the latest-ever semantics without issuing
// an unbounded TDengine query. MySQL supplies one persistent anchor per
// protocol; TDengine then reads only the day preceding that protocol's actual
// latest received time. If historical storage is unavailable, the projection
// itself remains useful evidence and the rest of the vehicle detail can load.
func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
snapshotStore, ok := s.store.(RealtimeSnapshotFrameStore)
if !ok {
return s.store.RawFrames(ctx, query)
}
anchors, err := snapshotStore.LatestRealtimeFrames(ctx, RawFrameQuery{
VIN: query.VIN,
Protocol: query.Protocol,
DateFrom: query.DateFrom,
DateTo: query.DateTo,
Fields: query.Fields,
IncludeFields: query.IncludeFields,
Limit: len(canonicalVehicleProtocols),
SkipCount: true,
})
if err != nil {
return Page[RawFrameRow]{}, err
}
if len(anchors.Items) == 0 {
limit := query.Limit
if limit <= 0 {
limit = 10
}
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil
}
results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items))
for _, anchor := range anchors.Items {
anchor := anchor
go func() {
bounded, valid := boundedRawPreviewQuery(query, anchor)
if !valid {
results <- vehicleDetailRawPreviewResult{anchor: anchor}
return
}
page, queryErr := s.store.RawFrames(ctx, bounded)
if queryErr != nil {
results <- vehicleDetailRawPreviewResult{anchor: anchor}
return
}
results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page}
}()
}
items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit)
for range anchors.Items {
result := <-results
if len(result.page.Items) == 0 {
items = append(items, result.anchor)
continue
}
items = append(items, result.page.Items...)
}
items = newestRawFrames(items, query.Limit)
limit := query.Limit
if limit <= 0 {
limit = 10
}
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
}
func boundedRawPreviewQuery(query RawFrameQuery, anchor RawFrameRow) (RawFrameQuery, bool) {
anchorTime, ok := parseVehicleServiceTime(firstNonEmpty(anchor.ServerTime, anchor.DeviceTime))
if !ok {
return RawFrameQuery{}, false
}
start := anchorTime.Add(-vehicleDetailRawProjectionLookback)
end := anchorTime.Add(time.Minute)
if value := strings.TrimSpace(query.DateFrom); value != "" {
if scopedStart, parsed := parseTrackRequestTime(value); parsed && scopedStart.After(start) {
start = scopedStart
}
}
if value := strings.TrimSpace(query.DateTo); value != "" {
if scopedEnd, parsed := parseTrackRequestTime(value); parsed && scopedEnd.Before(end) {
end = scopedEnd
}
}
if end.Before(start) {
return RawFrameQuery{}, false
}
bounded := query
bounded.Protocol = anchor.Protocol
bounded.DateFrom = start.Format(time.RFC3339Nano)
bounded.DateTo = end.Format(time.RFC3339Nano)
bounded.Offset = 0
bounded.SkipCount = true
return bounded, true
}
func newestRawFrames(items []RawFrameRow, limit int) []RawFrameRow {
if limit <= 0 {
limit = 10
}
deduplicated := make([]RawFrameRow, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "\x00" + strings.TrimSpace(item.ID)
if key != "\x00" && seen[key] {
continue
}
seen[key] = true
deduplicated = append(deduplicated, item)
}
sort.SliceStable(deduplicated, func(left, right int) bool {
leftTime, leftOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[left].ServerTime, deduplicated[left].DeviceTime))
rightTime, rightOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[right].ServerTime, deduplicated[right].DeviceTime))
if leftOK && rightOK && !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
if leftOK != rightOK {
return leftOK
}
if deduplicated[left].Protocol != deduplicated[right].Protocol {
return deduplicated[left].Protocol < deduplicated[right].Protocol
}
return deduplicated[left].ID < deduplicated[right].ID
})
if len(deduplicated) > limit {
deduplicated = deduplicated[:limit]
}
return deduplicated
}
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
vin = strings.TrimSpace(vin)
if vin == "" {
@@ -1587,6 +1735,7 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string {
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
_, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore)
for index := range statuses {
protocol := statuses[index].Protocol
if protocol == "" {
@@ -1599,7 +1748,7 @@ func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, sta
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
}
}
if !statuses[index].HasRaw && scopeErr == nil {
if !statuses[index].HasRaw && scopeErr == nil && !hasDurableRawProjection {
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true})
if err == nil && len(page.Items) > 0 {
statuses[index].HasRaw = true
@@ -1933,7 +2082,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
catalogResult := make(chan latestTelemetryCatalogResult, 1)
for _, protocol := range canonicalVehicleProtocols {
go func(protocol string) {
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
page, queryErr := s.vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
}(protocol)
}
@@ -5409,6 +5558,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
return result, nil
}
func (s *Service) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, error) {
vin = strings.ToUpper(strings.TrimSpace(vin))
date = strings.TrimSpace(date)
if len(vin) != 17 {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_VIN_INVALID", Message: "VIN格式不正确"}
}
if _, err := time.Parse("2006-01-02", date); err != nil {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_DATE_INVALID", Message: "统计日期格式不正确"}
}
if !hydrogenConsumptionAllowed(ctx) {
return HydrogenDailyEvidence{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看氢耗计算证据"}
}
query := url.Values{"vins": {vin}, "dateFrom": {date}, "dateTo": {date}}
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return HydrogenDailyEvidence{}, err
}
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
return HydrogenDailyEvidence{}, err
}
store, ok := s.store.(HydrogenDailyEvidenceStore)
if !ok {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "当前数据源尚未提供氢耗计算证据"}
}
result, found, err := store.HydrogenDailyEvidence(ctx, vin, date)
if err != nil {
return HydrogenDailyEvidence{}, err
}
if !found {
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "未找到该车辆当日氢耗计算证据"}
}
return result, nil
}
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
@@ -543,6 +543,7 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.
type countingStore struct {
*MockStore
rawMu sync.Mutex
vehiclesCalls int
vehicleRealtimeCalls int
overviewBatchCalls int
@@ -555,6 +556,17 @@ type countingStore struct {
lastMileageStatisticsQuery url.Values
}
type projectionCountingStore struct {
*countingStore
anchors Page[RawFrameRow]
projectionQueries []RawFrameQuery
}
func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.projectionQueries = append(s.projectionQueries, query)
return s.anchors, nil
}
type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
@@ -572,15 +584,26 @@ func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (Mile
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
consumption, rate := 3.1, 5.5
return MileageStatistics{
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
Trend: []MileageTrendPoint{{
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
}},
}, nil
}
func (s *hydrogenMetricsStore) HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) {
return HydrogenDailyEvidence{
VIN: "LB9A32A24R0LS1426",
Date: "2026-07-13",
AlgorithmVersion: "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
Intervals: []HydrogenIntervalEvidenceRow{{
Index: 1, StartEventID: "event-start", EndEventID: "event-end", QualityStatus: "OK",
}},
}, true, nil
}
func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()}
}
@@ -622,6 +645,27 @@ func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
}
evidence, err := service.HydrogenDailyEvidence(exportAdminContext(), "LB9A32A24R0LS1426", "2026-07-13")
if err != nil {
t.Fatal(err)
}
if evidence.AlgorithmVersion == "" || len(evidence.Intervals) != 1 || evidence.Intervals[0].StartEventID != "event-start" {
t.Fatalf("internal hydrogen evidence is incomplete: %+v", evidence)
}
}
func TestHydrogenDailyEvidenceIsDeniedToCustomerAccounts(t *testing.T) {
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
customer := WithPrincipal(context.Background(), Principal{
Name: "业务客户", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
})
_, err := service.HydrogenDailyEvidence(customer, "LB9A32A24R0LS1426", "2026-07-13")
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
t.Fatalf("customer hydrogen evidence should be forbidden, err=%v", err)
}
}
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
@@ -643,7 +687,9 @@ func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query
}
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.rawMu.Lock()
s.rawFrameQueries = append(s.rawFrameQueries, query)
s.rawMu.Unlock()
return s.MockStore.RawFrames(ctx, query)
}
@@ -692,6 +738,66 @@ func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
}
}
func TestVehicleDetailUsesPersistentProjectionAsBoundedHistoricalAnchor(t *testing.T) {
base := newCountingStore()
store := &projectionCountingStore{
countingStore: base,
anchors: Page[RawFrameRow]{Items: []RawFrameRow{
{ID: "snapshot-gb", VIN: "LB9A32A24R0LS1426", Protocol: "GB32960", FrameType: "realtime_projection", DeviceTime: "2025-03-04T12:00:00+08:00", ServerTime: "2025-03-04T12:00:01+08:00", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 0}},
{ID: "snapshot-jt", VIN: "LB9A32A24R0LS1426", Protocol: "JT808", FrameType: "realtime_projection", DeviceTime: "2025-02-03T10:00:00+08:00", ServerTime: "2025-02-03T10:00:01+08:00", ParsedFields: map[string]any{"jt808.location.speed_kmh": 0}},
{ID: "snapshot-mqtt", VIN: "LB9A32A24R0LS1426", Protocol: "YUTONG_MQTT", FrameType: "realtime_projection", DeviceTime: "2025-01-01T08:00:00+08:00", ServerTime: "2025-01-01T08:00:01+08:00", ParsedFields: map[string]any{"yutong_mqtt.data.speed_kmh": 0}},
}},
}
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if len(store.projectionQueries) != 1 || store.projectionQueries[0].VIN != "LB9A32A24R0LS1426" {
t.Fatalf("projection queries = %+v", store.projectionQueries)
}
if len(store.rawFrameQueries) != 3 {
t.Fatalf("bounded protocol RAW queries = %+v", store.rawFrameQueries)
}
for _, query := range store.rawFrameQueries {
if query.Protocol == "" || query.DateFrom == "" || query.DateTo == "" || !query.SkipCount {
t.Fatalf("RAW preview must be protocol-specific, time-bounded, and count-free: %+v", query)
}
if strings.HasPrefix(query.DateFrom, "2026-") || strings.HasPrefix(query.DateTo, "2026-") {
t.Fatalf("offline projection should anchor its actual historical day, not today: %+v", query)
}
if !strings.Contains(query.DateFrom, "+08:00") || !strings.Contains(query.DateTo, "+08:00") {
t.Fatalf("bounded RAW preview should preserve the platform timezone: %+v", query)
}
}
if len(detail.Raw.Items) != 3 {
t.Fatalf("bounded preview should preserve every latest protocol row: %+v", detail.Raw)
}
projectionFallbacks := 0
for _, item := range detail.Raw.Items {
if item.FrameType == "realtime_projection" {
projectionFallbacks++
}
}
if projectionFallbacks != 2 {
t.Fatalf("missing projection fallbacks for protocols without RAW test rows: %+v", detail.Raw.Items)
}
}
func TestVehicleDetailDoesNotFallBackToUnboundedRAWWhenProjectionIsEmpty(t *testing.T) {
base := newCountingStore()
store := &projectionCountingStore{countingStore: base, anchors: Page[RawFrameRow]{Items: []RawFrameRow{}}}
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if len(store.rawFrameQueries) != 0 {
t.Fatalf("empty durable projection must not trigger an unbounded RAW scan: %+v", store.rawFrameQueries)
}
if detail.Raw.Items == nil || len(detail.Raw.Items) != 0 || detail.Raw.Total != 0 {
t.Fatalf("empty projection should return a stable empty preview: %+v", detail.Raw)
}
}
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
s.lastMileageStatisticsQuery = cloneValues(query)
return s.MockStore.MileageStatistics(ctx, query)