Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7e7176f88 | ||
|
|
9f0a87f49e | ||
|
|
1849bc1870 | ||
|
|
4ab654e1c1 | ||
|
|
a6e31effc2 |
@@ -633,111 +633,6 @@ WHERE m.protocol = 'GB32960' AND m.daily_mileage_km > 0
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyMigration(ctx context.Context, conn *sql.Conn) error {
|
func applyMigration(ctx context.Context, conn *sql.Conn) error {
|
||||||
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
// The legacy table has no protocol provenance. Never label its rows GB32960.
|
||||||
if err != nil {
|
return errors.New("legacy mileage migration disabled: source rows have no verified protocol; retain the legacy table without projecting protocol mileage")
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO vehicle_data_source
|
|
||||||
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind,
|
|
||||||
trust_priority, enabled, first_seen_at, latest_seen_at, remark)
|
|
||||||
VALUES ('GB32960', ?, 'lingniu_prod.ln_vehicle_day_mileage', ?, ?, 'UNKNOWN', 1000, 1,
|
|
||||||
'2024-10-17 00:00:00', '2026-07-20 23:59:59',
|
|
||||||
'仅用于迁移 2026-07-21 之前缺失或为 0 的 GB32960 日里程')
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
latest_source_endpoint = VALUES(latest_source_endpoint),
|
|
||||||
platform_name = VALUES(platform_name),
|
|
||||||
source_code = VALUES(source_code),
|
|
||||||
source_kind = VALUES(source_kind),
|
|
||||||
trust_priority = VALUES(trust_priority),
|
|
||||||
enabled = 1,
|
|
||||||
remark = VALUES(remark)`, legacySourceIP, legacyPlatformName, legacySourceCode); err != nil {
|
|
||||||
return fmt.Errorf("upsert legacy data source: %w", err)
|
|
||||||
}
|
|
||||||
var sourceID int64
|
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM vehicle_data_source WHERE protocol = 'GB32960' AND source_ip = ?`, legacySourceIP).Scan(&sourceID); err != nil {
|
|
||||||
return fmt.Errorf("lookup legacy data source: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO vehicle_daily_mileage_source
|
|
||||||
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, platform_name,
|
|
||||||
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
|
|
||||||
first_event_time, latest_event_time, quality_status, quality_reason, is_selected)
|
|
||||||
SELECT
|
|
||||||
st.vin, st.stat_date, 'GB32960', ?, ?, 'lingniu_prod.ln_vehicle_day_mileage', ?,
|
|
||||||
CASE WHEN st.total_mileage_km IS NOT NULL AND st.total_mileage_km >= st.daily_mileage_km
|
|
||||||
THEN st.total_mileage_km - st.daily_mileage_km ELSE NULL END,
|
|
||||||
st.total_mileage_km, st.daily_mileage_km, 1,
|
|
||||||
TIMESTAMP(st.stat_date, '00:00:00'), TIMESTAMP(st.stat_date, '23:59:59'),
|
|
||||||
'OK', ?, 0
|
|
||||||
FROM tmp_legacy_mileage_stage st
|
|
||||||
LEFT JOIN vehicle_daily_mileage m
|
|
||||||
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
|
|
||||||
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
updated_at = IF(
|
|
||||||
NOT (vehicle_daily_mileage_source.first_total_mileage_km <=> VALUES(first_total_mileage_km))
|
|
||||||
OR NOT (vehicle_daily_mileage_source.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
|
|
||||||
OR NOT (vehicle_daily_mileage_source.daily_mileage_km <=> VALUES(daily_mileage_km))
|
|
||||||
OR NOT (vehicle_daily_mileage_source.quality_status <=> VALUES(quality_status))
|
|
||||||
OR NOT (vehicle_daily_mileage_source.quality_reason <=> VALUES(quality_reason)),
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
vehicle_daily_mileage_source.updated_at
|
|
||||||
),
|
|
||||||
source_ip = VALUES(source_ip),
|
|
||||||
source_endpoint = VALUES(source_endpoint),
|
|
||||||
platform_name = VALUES(platform_name),
|
|
||||||
first_total_mileage_km = VALUES(first_total_mileage_km),
|
|
||||||
latest_total_mileage_km = VALUES(latest_total_mileage_km),
|
|
||||||
daily_mileage_km = VALUES(daily_mileage_km),
|
|
||||||
sample_count = VALUES(sample_count),
|
|
||||||
first_event_time = VALUES(first_event_time),
|
|
||||||
latest_event_time = VALUES(latest_event_time),
|
|
||||||
quality_status = VALUES(quality_status),
|
|
||||||
quality_reason = VALUES(quality_reason)`, legacySourceKey, legacySourceIP, legacyPlatformName, legacyQuality, sourceID); err != nil {
|
|
||||||
return fmt.Errorf("upsert legacy mileage candidates: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO vehicle_daily_mileage
|
|
||||||
(vin, stat_date, protocol, source_id, daily_mileage_km, latest_total_mileage_km)
|
|
||||||
SELECT st.vin, st.stat_date, 'GB32960', ?, st.daily_mileage_km, st.total_mileage_km
|
|
||||||
FROM tmp_legacy_mileage_stage st
|
|
||||||
LEFT JOIN vehicle_daily_mileage m
|
|
||||||
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
|
|
||||||
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
updated_at = IF(
|
|
||||||
vehicle_daily_mileage.daily_mileage_km = 0
|
|
||||||
OR (
|
|
||||||
(vehicle_daily_mileage.source_id <=> VALUES(source_id))
|
|
||||||
AND (
|
|
||||||
NOT (vehicle_daily_mileage.daily_mileage_km <=> VALUES(daily_mileage_km))
|
|
||||||
OR NOT (vehicle_daily_mileage.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
|
|
||||||
)
|
|
||||||
),
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
vehicle_daily_mileage.updated_at
|
|
||||||
),
|
|
||||||
source_id = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(source_id), vehicle_daily_mileage.source_id),
|
|
||||||
latest_total_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(latest_total_mileage_km), vehicle_daily_mileage.latest_total_mileage_km),
|
|
||||||
daily_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(daily_mileage_km), vehicle_daily_mileage.daily_mileage_km)`, sourceID, sourceID); err != nil {
|
|
||||||
return fmt.Errorf("upsert final daily mileage: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
UPDATE vehicle_daily_mileage_source s
|
|
||||||
JOIN tmp_legacy_mileage_stage st
|
|
||||||
ON st.vin = s.vin AND st.stat_date = s.stat_date
|
|
||||||
JOIN vehicle_daily_mileage m
|
|
||||||
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
|
|
||||||
SET s.is_selected = CASE WHEN s.source_key = ? THEN 1 ELSE 0 END
|
|
||||||
WHERE s.protocol = 'GB32960'
|
|
||||||
AND m.source_id = ?
|
|
||||||
AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.0005`, legacySourceKey, sourceID); err != nil {
|
|
||||||
return fmt.Errorf("mark legacy mileage selections: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("commit migration: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,3 +56,10 @@ func TestNullableTotalMileageConvertsMetersToKM(t *testing.T) {
|
|||||||
t.Fatalf("invalid mileage should remain nil, got %#v", got)
|
t.Fatalf("invalid mileage should remain nil, got %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyRejectsUnknownProtocolBeforeDatabaseAccess(t *testing.T) {
|
||||||
|
err := applyMigration(context.Background(), nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no verified protocol") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Reproject explicitly listed, backed-up mileage days using the live writer SQL.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||||
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
input := flag.String("input", "", "JSON array of vin/date/protocol")
|
||||||
|
flag.Parse()
|
||||||
|
b, e := os.ReadFile(*input)
|
||||||
|
must(e)
|
||||||
|
var rows []struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
must(json.Unmarshal(b, &rows))
|
||||||
|
db, e := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||||
|
must(e)
|
||||||
|
defer db.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
for i, r := range rows {
|
||||||
|
must(stats.ProjectDailyMileage(ctx, db, r.VIN, r.Date, envelope.Protocol(r.Protocol)))
|
||||||
|
if (i+1)%500 == 0 {
|
||||||
|
fmt.Printf("projected=%d/%d\n", i+1, len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("completed=%d\n", len(rows))
|
||||||
|
}
|
||||||
|
func must(e error) {
|
||||||
|
if e != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, e)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@ func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAggregateFromDailySourceUsesCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
|
func TestAggregateFromDailySourceUsesHistoricalBoundaryAfterOfflineGap(t *testing.T) {
|
||||||
current := dailySourceLast{
|
current := dailySourceLast{
|
||||||
VIN: "LMRKH9AC2R1004087",
|
VIN: "LMRKH9AC2R1004087",
|
||||||
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
||||||
@@ -213,13 +213,13 @@ func TestAggregateFromDailySourceUsesCurrentDayBoundaryAfterOfflineGap(t *testin
|
|||||||
|
|
||||||
agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, previous, true)
|
agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, previous, true)
|
||||||
|
|
||||||
if agg.FirstKM != 120778 || agg.LatestKM != 120788 {
|
if agg.FirstKM != 120672 || agg.LatestKM != 120788 {
|
||||||
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
||||||
}
|
}
|
||||||
if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS {
|
if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS {
|
||||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||||
}
|
}
|
||||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
|
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonHistorical {
|
||||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||||
}
|
}
|
||||||
if agg.Count != 15 {
|
if agg.Count != 15 {
|
||||||
@@ -227,7 +227,7 @@ func TestAggregateFromDailySourceUsesCurrentDayBoundaryAfterOfflineGap(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAggregateFromDailySourceIgnoresHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
|
func TestAggregateFromDailySourceRejectsHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
|
||||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
current := dailySourceLast{
|
current := dailySourceLast{
|
||||||
VIN: "LNXNEGRR6SR319464",
|
VIN: "LNXNEGRR6SR319464",
|
||||||
@@ -249,10 +249,10 @@ func TestAggregateFromDailySourceIgnoresHistoricalBaselineJumpAcrossOfflineGap(t
|
|||||||
|
|
||||||
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
|
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
|
||||||
|
|
||||||
if agg.FirstKM != current.FirstTotalKM || agg.LatestKM != current.TotalKM {
|
if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM {
|
||||||
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
||||||
}
|
}
|
||||||
if !agg.FirstEventTime.Equal(current.FirstTS) || !agg.LatestEventTime.Equal(current.TS) {
|
if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) {
|
||||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||||
}
|
}
|
||||||
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
|
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
|
||||||
@@ -286,7 +286,7 @@ func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildLastDiffAggregatesStartsFreshBoundaryAfterEmptyDays(t *testing.T) {
|
func TestBuildLastDiffAggregatesRetainsBoundaryAfterEmptyDays(t *testing.T) {
|
||||||
tdDB, mock, err := sqlmock.New()
|
tdDB, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -334,11 +334,11 @@ func TestBuildLastDiffAggregatesStartsFreshBoundaryAfterEmptyDays(t *testing.T)
|
|||||||
if agg == nil {
|
if agg == nil {
|
||||||
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
||||||
}
|
}
|
||||||
if agg.FirstKM != 120 || agg.LatestKM != 120 {
|
if agg.FirstKM != 100 || agg.LatestKM != 120 {
|
||||||
t.Fatalf("km range = %v -> %v, want 120 -> 120", agg.FirstKM, agg.LatestKM)
|
t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
|
||||||
}
|
}
|
||||||
if !agg.FirstEventTime.Equal(dayThreeTS) || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
|
if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
|
||||||
t.Fatalf("baseline = %v reason=%q, want day-three current-day baseline", agg.FirstEventTime, agg.QualityReason)
|
t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations: %v", err)
|
t.Fatalf("sql expectations: %v", err)
|
||||||
@@ -602,10 +602,10 @@ func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *tes
|
|||||||
if agg == nil {
|
if agg == nil {
|
||||||
t.Fatal("missing realtime-location fallback aggregate")
|
t.Fatal("missing realtime-location fallback aggregate")
|
||||||
}
|
}
|
||||||
if agg.FirstKM != 120 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayThreeTS) {
|
if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
|
||||||
t.Fatalf("fallback range = %v@%v -> %v, want 120@day-three -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
|
t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
|
||||||
}
|
}
|
||||||
if agg.QualityReason != "realtime_location_fallback_current_day_first_baseline" {
|
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
||||||
t.Fatalf("quality reason = %q", agg.QualityReason)
|
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||||
}
|
}
|
||||||
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
||||||
|
|||||||
@@ -924,9 +924,7 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !IsUsableDailyMileageBoundary(candidate.StatDate, baseline.LatestEventTime, w.loc) {
|
if !IsUsableDailyMileageBoundary(candidate.StatDate, baseline.LatestEventTime, w.loc) {
|
||||||
// A vehicle recovering after one or more empty natural days must start a
|
// A future/invalid boundary cannot be used for this natural day.
|
||||||
// fresh day boundary. Otherwise the complete offline-period odometer
|
|
||||||
// increase is incorrectly assigned to the recovery day.
|
|
||||||
candidate.FirstTotalKM = candidate.LatestTotalKM
|
candidate.FirstTotalKM = candidate.LatestTotalKM
|
||||||
candidate.FirstEventTime = candidate.LatestEventTime
|
candidate.FirstEventTime = candidate.LatestEventTime
|
||||||
candidate.DailyKM = 0
|
candidate.DailyKM = 0
|
||||||
|
|||||||
@@ -1453,7 +1453,7 @@ func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriterAppendStartsCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
|
func TestWriterAppendUsesHistoricalBoundaryAfterOfflineGap(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -1494,18 +1494,18 @@ func TestWriterAppendStartsCurrentDayBoundaryAfterOfflineGap(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
"LMRKH9AC2R1004087",
|
"LMRKH9AC2R1004087",
|
||||||
"",
|
"",
|
||||||
|
float64(120672.0),
|
||||||
float64(120788.0),
|
float64(120788.0),
|
||||||
float64(120788.0),
|
float64(116),
|
||||||
float64(0),
|
|
||||||
float64(0),
|
float64(0),
|
||||||
int64(1),
|
int64(1),
|
||||||
int64(0),
|
int64(0),
|
||||||
currentTime,
|
historicalTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
QualityOK,
|
QualityOK,
|
||||||
QualityReasonCurrentDayFirst,
|
QualityReasonHistorical,
|
||||||
).
|
).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
||||||
@@ -1711,7 +1711,7 @@ func TestWriterApplyRealtimeBaselineRejectsNegativeDeltaEvenWithLowerCurrentDayS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriterApplyRealtimeBaselineIgnoresPlausibleMultiDayFallbackDelta(t *testing.T) {
|
func TestWriterApplyRealtimeBaselineIncludesPlausibleMultiDayFallbackDelta(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -1740,14 +1740,14 @@ func TestWriterApplyRealtimeBaselineIgnoresPlausibleMultiDayFallbackDelta(t *tes
|
|||||||
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
||||||
}
|
}
|
||||||
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonCurrentDayFirst {
|
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonHistorical {
|
||||||
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM != 0 {
|
if math.Abs(candidate.DailyKM-7833.5) > 0.000001 {
|
||||||
t.Fatalf("daily km = %v, want current-day first baseline after offline gap", candidate.DailyKM)
|
t.Fatalf("daily km = %v, want historical cumulative difference after offline gap", candidate.DailyKM)
|
||||||
}
|
}
|
||||||
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
|
if candidate.FirstTotalKM != 8832.1 || !candidate.FirstEventTime.Equal(baselineTime) {
|
||||||
t.Fatalf("current-day boundary not retained: %#v", candidate)
|
t.Fatalf("historical boundary not retained: %#v", candidate)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations: %v", err)
|
t.Fatalf("sql expectations: %v", err)
|
||||||
@@ -1798,7 +1798,7 @@ func TestWriterApplyRealtimeBaselineRejectsProtocolOdometerRollbackAfterSourceCh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriterApplyRealtimeBaselineIgnoresHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
|
func TestWriterApplyRealtimeBaselineRejectsHistoricalBaselineJumpAcrossOfflineGap(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sqlmock.New() error = %v", err)
|
t.Fatalf("sqlmock.New() error = %v", err)
|
||||||
@@ -1827,14 +1827,14 @@ func TestWriterApplyRealtimeBaselineIgnoresHistoricalBaselineJumpAcrossOfflineGa
|
|||||||
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
if err := writer.applyRealtimeBaseline(context.Background(), &candidate); err != nil {
|
||||||
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
t.Fatalf("applyRealtimeBaseline() error = %v", err)
|
||||||
}
|
}
|
||||||
if candidate.QualityStatus != QualityOK || candidate.QualityReason != QualityReasonCurrentDayFirst {
|
if candidate.QualityStatus != QualityInvalidDelta || candidate.QualityReason != "outside_daily_range" {
|
||||||
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
t.Fatalf("quality = %s/%s", candidate.QualityStatus, candidate.QualityReason)
|
||||||
}
|
}
|
||||||
if candidate.DailyKM != 0 {
|
if math.Abs(candidate.DailyKM-30000) > 0.000001 {
|
||||||
t.Fatalf("daily km = %v, want current-day first baseline", candidate.DailyKM)
|
t.Fatalf("daily km = %v, want historical cumulative difference", candidate.DailyKM)
|
||||||
}
|
}
|
||||||
if candidate.FirstTotalKM != candidate.LatestTotalKM || !candidate.FirstEventTime.Equal(currentTime) {
|
if candidate.FirstTotalKM != 10009.7 || !candidate.FirstEventTime.Equal(baselineTime) {
|
||||||
t.Fatalf("current-day boundary not retained: %#v", candidate)
|
t.Fatalf("historical boundary not retained: %#v", candidate)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations: %v", err)
|
t.Fatalf("sql expectations: %v", err)
|
||||||
|
|||||||
@@ -137,10 +137,8 @@ func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM
|
|||||||
return currentDayLatestKM - previousBaselineKM
|
return currentDayLatestKM - previousBaselineKM
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUsableDailyMileageBoundary reports whether a persisted baseline belongs to
|
// IsUsableDailyMileageBoundary accepts older same-source odometers.
|
||||||
// the current natural day or its immediately preceding natural day. A baseline
|
// Offline increments belong to the recovery day so cumulative totals reconcile.
|
||||||
// from an older day represents an offline gap; using it would attribute the
|
|
||||||
// whole gap to the day on which the vehicle came back online.
|
|
||||||
func IsUsableDailyMileageBoundary(statDate string, baselineTime time.Time, loc *time.Location) bool {
|
func IsUsableDailyMileageBoundary(statDate string, baselineTime time.Time, loc *time.Location) bool {
|
||||||
if strings.TrimSpace(statDate) == "" || baselineTime.IsZero() {
|
if strings.TrimSpace(statDate) == "" || baselineTime.IsZero() {
|
||||||
return false
|
return false
|
||||||
@@ -154,7 +152,7 @@ func IsUsableDailyMileageBoundary(statDate string, baselineTime time.Time, loc *
|
|||||||
}
|
}
|
||||||
baseline := baselineTime.In(loc)
|
baseline := baselineTime.In(loc)
|
||||||
baselineDay := time.Date(baseline.Year(), baseline.Month(), baseline.Day(), 0, 0, 0, 0, loc)
|
baselineDay := time.Date(baseline.Year(), baseline.Month(), baseline.Day(), 0, 0, 0, 0, loc)
|
||||||
return !baselineDay.Before(day.AddDate(0, 0, -1)) && !baselineDay.After(day)
|
return !baselineDay.After(day)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
|
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
|
||||||
@@ -754,7 +752,9 @@ const selectedSourceIdentitySampleCountSQL = `(
|
|||||||
// from the daily-mileage projection. GPS coordinate accumulation can be the
|
// from the daily-mileage projection. GPS coordinate accumulation can be the
|
||||||
// best evidence for distance travelled during the day, but it must never
|
// best evidence for distance travelled during the day, but it must never
|
||||||
// replace a terminal-reported odometer as the day-end cumulative mileage.
|
// replace a terminal-reported odometer as the day-end cumulative mileage.
|
||||||
const projectDayEndTotalMileageSQL = `COALESCE((
|
const projectDayEndTotalMileageSQL = `CASE
|
||||||
|
WHEN COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN s.latest_total_mileage_km
|
||||||
|
ELSE COALESCE((
|
||||||
SELECT total_candidate.latest_total_mileage_km
|
SELECT total_candidate.latest_total_mileage_km
|
||||||
FROM vehicle_daily_mileage_source total_candidate
|
FROM vehicle_daily_mileage_source total_candidate
|
||||||
WHERE total_candidate.vin = s.vin
|
WHERE total_candidate.vin = s.vin
|
||||||
@@ -771,7 +771,7 @@ const projectDayEndTotalMileageSQL = `COALESCE((
|
|||||||
total_candidate.sample_count DESC,
|
total_candidate.sample_count DESC,
|
||||||
total_candidate.source_key ASC
|
total_candidate.source_key ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
), s.latest_total_mileage_km)`
|
), s.latest_total_mileage_km) END`
|
||||||
|
|
||||||
const projectDailyMileageSQL = `
|
const projectDailyMileageSQL = `
|
||||||
INSERT INTO vehicle_daily_mileage
|
INSERT INTO vehicle_daily_mileage
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func TestDailyMileageFromDayBoundaryUsesCurrentMinusPrevious(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsUsableDailyMileageBoundaryRejectsOlderOfflineGap(t *testing.T) {
|
func TestIsUsableDailyMileageBoundaryAcceptsOlderOfflineGap(t *testing.T) {
|
||||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -288,7 +288,7 @@ func TestIsUsableDailyMileageBoundaryRejectsOlderOfflineGap(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "current day cache", baseline: time.Date(2026, 8, 4, 19, 1, 26, 0, loc), want: true},
|
{name: "current day cache", baseline: time.Date(2026, 8, 4, 19, 1, 26, 0, loc), want: true},
|
||||||
{name: "previous natural day", baseline: time.Date(2026, 8, 3, 23, 59, 59, 0, loc), want: true},
|
{name: "previous natural day", baseline: time.Date(2026, 8, 3, 23, 59, 59, 0, loc), want: true},
|
||||||
{name: "older offline gap", baseline: time.Date(2026, 5, 21, 23, 13, 2, 0, loc), want: false},
|
{name: "older offline gap", baseline: time.Date(2026, 5, 21, 23, 13, 2, 0, loc), want: true},
|
||||||
{name: "future boundary", baseline: time.Date(2026, 8, 5, 0, 0, 0, 0, loc), want: false},
|
{name: "future boundary", baseline: time.Date(2026, 8, 5, 0, 0, 0, 0, loc), want: false},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Consistent logical backup of the complete current MySQL database; no DB writes."""
|
||||||
|
import gzip, hashlib, json, os, pathlib, time
|
||||||
|
import pymysql
|
||||||
|
from db_access import connect
|
||||||
|
os.umask(0o077)
|
||||||
|
root=pathlib.Path('/opt/lingniu-go-native/backups/mileage-reconciliation-20260916')
|
||||||
|
root.mkdir(parents=True,exist_ok=True)
|
||||||
|
path=root/'database.sql.gz'
|
||||||
|
if path.exists(): raise RuntimeError('Backup already exists; do not overwrite')
|
||||||
|
c=connect(); manifest={'started_at':time.strftime('%Y-%m-%dT%H:%M:%S%z'),'tables':{}}
|
||||||
|
with c.cursor() as q:
|
||||||
|
q.execute('SELECT DATABASE() AS db'); db=q.fetchone()['db'];manifest['database']=db
|
||||||
|
q.execute('SHOW FULL TABLES'); entries=q.fetchall()
|
||||||
|
tables=[list(r.values())[0] for r in entries if list(r.values())[1]=='BASE TABLE']
|
||||||
|
q.execute('SHOW TRIGGERS'); triggers=q.fetchall()
|
||||||
|
q.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
|
||||||
|
def literal(v):
|
||||||
|
if isinstance(v,bytes): return '0x'+v.hex()
|
||||||
|
return c.escape(v)
|
||||||
|
with gzip.open(str(path)+'.partial','wt',encoding='utf-8',compresslevel=3) as f:
|
||||||
|
f.write('SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n')
|
||||||
|
for table in tables:
|
||||||
|
q.execute('SHOW CREATE TABLE `'+table+'`');ddl=list(q.fetchone().values())[1]
|
||||||
|
f.write('DROP TABLE IF EXISTS `'+table+'`;\n'+ddl+';\n')
|
||||||
|
q.execute('SHOW COLUMNS FROM `'+table+'`');cols=[r['Field'] for r in q.fetchall() if 'GENERATED' not in r['Extra']]
|
||||||
|
names=','.join('`'+v+'`' for v in cols);count=0
|
||||||
|
with c.cursor(pymysql.cursors.SSCursor) as stream:
|
||||||
|
stream.execute('SELECT '+names+' FROM `'+table+'`')
|
||||||
|
while True:
|
||||||
|
rows=stream.fetchmany(500)
|
||||||
|
if not rows:break
|
||||||
|
f.write('INSERT INTO `'+table+'` ('+names+') VALUES\n'+',\n'.join('('+','.join(literal(v) for v in row)+')' for row in rows)+';\n')
|
||||||
|
count+=len(rows)
|
||||||
|
manifest['tables'][table]=count
|
||||||
|
print(json.dumps({'table':table,'rows':count}),flush=True)
|
||||||
|
for row in entries:
|
||||||
|
if list(row.values())[1]=='VIEW':
|
||||||
|
name=list(row.values())[0];q.execute('SHOW CREATE VIEW `'+name+'`');ddl=q.fetchone()['Create View'];f.write(ddl+';\n')
|
||||||
|
for trigger in triggers:
|
||||||
|
q.execute('SHOW CREATE TRIGGER `'+trigger['Trigger']+'`');ddl=q.fetchone()['SQL Original Statement'];f.write('DELIMITER ;;\n'+ddl+';;\nDELIMITER ;\n')
|
||||||
|
f.write('SET FOREIGN_KEY_CHECKS=1;\n')
|
||||||
|
c.rollback();c.close()
|
||||||
|
os.rename(str(path)+'.partial',path)
|
||||||
|
h=hashlib.sha256()
|
||||||
|
with path.open('rb') as f:
|
||||||
|
for block in iter(lambda:f.read(1024*1024),b''):h.update(block)
|
||||||
|
manifest.update(sha256=h.hexdigest(),bytes=path.stat().st_size,finished_at=time.strftime('%Y-%m-%dT%H:%M:%S%z'))
|
||||||
|
(root/'manifest.json').write_text(json.dumps(manifest,indent=2))
|
||||||
|
print(json.dumps({'complete':True,'bytes':manifest['bytes'],'sha256':manifest['sha256']}),flush=True)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import pymysql,pathlib,re,json
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
env={}
|
||||||
|
for name in ['base.env','stat-writer.env']:
|
||||||
|
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
|
||||||
|
if '=' in line and not line.startswith('#'):
|
||||||
|
k,v=line.split('=',1);env[k]=v.strip().strip(chr(34)).strip(chr(39))
|
||||||
|
m=re.fullmatch(r'([^:]+):(.*?)@tcp\(([^:]+):(\d+)\)/([^?]+).*',env['MYSQL_DSN'])
|
||||||
|
return pymysql.connect(user=m[1],password=m[2],host=m[3],port=int(m[4]),database=m[5],charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,autocommit=False)
|
||||||
|
if __name__=='__main__':
|
||||||
|
c=connect()
|
||||||
|
with c.cursor() as q:
|
||||||
|
q.execute('START TRANSACTION READ ONLY')
|
||||||
|
for sql in ["SELECT DATABASE() AS db, VERSION() AS version", "SELECT table_name,engine,table_rows,ROUND((data_length+index_length)/1024/1024,1) AS mb FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY data_length DESC", "SELECT protocol,source_ip,COUNT(*) AS n,COUNT(DISTINCT vin) AS vins FROM vehicle_daily_mileage_source WHERE source_ip IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage') GROUP BY protocol,source_ip"]:
|
||||||
|
q.execute(sql);print(json.dumps(q.fetchall(),default=str))
|
||||||
|
c.rollback();c.close()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Repair verified mileage provenance and offline baselines. Dry-run by default."""
|
||||||
|
import argparse,datetime,json,pathlib
|
||||||
|
from decimal import Decimal
|
||||||
|
from db_access import connect
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('--apply',action='store_true');args=p.parse_args()
|
||||||
|
root=pathlib.Path('/opt/lingniu-go-native/backups/mileage-reconciliation-20260916')
|
||||||
|
legacy="('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')"
|
||||||
|
c=connect();report={}
|
||||||
|
with c.cursor() as q:
|
||||||
|
q.execute('START TRANSACTION READ ONLY')
|
||||||
|
q.execute("SELECT COUNT(*) AS n,COUNT(DISTINCT vin) AS vehicles FROM vehicle_daily_mileage_source WHERE protocol='GB32960' AND source_ip IN "+legacy);report['legacy']=q.fetchone()
|
||||||
|
q.execute("SELECT COUNT(*) AS n FROM vehicle_daily_mileage m JOIN vehicle_data_source d ON d.id=m.source_id WHERE m.protocol='GB32960' AND d.source_ip IN "+legacy);report['legacy_projections']=q.fetchone()
|
||||||
|
q.execute("""SELECT s.vin,s.stat_date,s.protocol,s.source_key,s.latest_total_mileage_km,s.first_total_mileage_km,s.daily_mileage_km,s.first_event_time,s.latest_event_time,
|
||||||
|
p.latest_total_mileage_km AS previous_total,p.latest_event_time AS previous_time
|
||||||
|
FROM vehicle_daily_mileage_source s
|
||||||
|
JOIN vehicle_daily_mileage_source p ON p.vin=s.vin AND p.protocol=s.protocol AND p.source_key=s.source_key
|
||||||
|
AND p.stat_date=(SELECT MAX(b.stat_date) FROM vehicle_daily_mileage_source b
|
||||||
|
WHERE b.vin=s.vin AND b.protocol=s.protocol AND b.source_key=s.source_key AND b.stat_date<s.stat_date
|
||||||
|
AND b.quality_status='OK' AND b.latest_total_mileage_km>0
|
||||||
|
AND b.latest_event_time>=TIMESTAMP(b.stat_date) AND b.latest_event_time<TIMESTAMP(b.stat_date)+INTERVAL 1 DAY)
|
||||||
|
WHERE s.stat_date<='2026-09-16' AND s.quality_status='OK'
|
||||||
|
AND s.quality_reason='current_day_first_baseline'
|
||||||
|
AND s.latest_total_mileage_km>0 AND s.first_event_time>=TIMESTAMP(s.stat_date)
|
||||||
|
AND s.source_ip NOT IN """+legacy+" ORDER BY s.vin,s.protocol,s.source_key,s.stat_date")
|
||||||
|
changes=q.fetchall();report['baseline_candidates']=len(changes)
|
||||||
|
q.execute("SELECT id FROM vehicle_data_source WHERE source_ip IN "+legacy);legacy_ids=[r['id'] for r in q.fetchall()]
|
||||||
|
c.rollback()
|
||||||
|
if args.apply:
|
||||||
|
assert (root/'manifest.json').is_file() and (root/'database.sql.gz').is_file(),'Complete backup required'
|
||||||
|
(root/'baseline-before.json').write_text(json.dumps(changes,default=str,ensure_ascii=False))
|
||||||
|
q.execute('START TRANSACTION')
|
||||||
|
q.execute("UPDATE vehicle_daily_mileage_source SET quality_status='INVALID_DELTA',quality_reason='UNVERIFIED_LEGACY_PROTOCOL',is_selected=0 WHERE protocol='GB32960' AND source_ip IN "+legacy)
|
||||||
|
report['quarantined_source_rows']=q.rowcount
|
||||||
|
q.execute("UPDATE vehicle_data_source SET enabled=0,remark='Protocol provenance unverified; quarantined 2026-09-16; original records retained' WHERE protocol='GB32960' AND source_ip IN "+legacy)
|
||||||
|
q.execute("DELETE m FROM vehicle_daily_mileage m JOIN vehicle_data_source d ON d.id=m.source_id WHERE m.protocol='GB32960' AND d.source_ip IN "+legacy)
|
||||||
|
report['removed_mislabelled_projections']=q.rowcount
|
||||||
|
affected=set()
|
||||||
|
for s in changes:
|
||||||
|
delta=s['latest_total_mileage_km']-s['previous_total']
|
||||||
|
days=max(1,(s['latest_event_time'].date()-s['previous_time'].date()).days)
|
||||||
|
status='OK';reason='historical_source_baseline'
|
||||||
|
if Decimal('-1')<=delta<0:delta=Decimal(0);reason='negative_jitter_clamped'
|
||||||
|
elif delta<0:status='INVALID_DELTA';reason='TOTAL_MILEAGE_ROLLBACK'
|
||||||
|
elif delta>Decimal(2500*days):status='INVALID_DELTA';reason='outside_daily_range'
|
||||||
|
q.execute("""UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=%s,first_event_time=%s,daily_mileage_km=%s,
|
||||||
|
quality_status=%s,quality_reason=%s WHERE vin=%s AND stat_date=%s AND protocol=%s AND source_key=%s
|
||||||
|
AND first_total_mileage_km <=> %s AND daily_mileage_km=%s""",(s['previous_total'],s['previous_time'],delta,status,reason,s['vin'],s['stat_date'],s['protocol'],s['source_key'],s['first_total_mileage_km'],s['daily_mileage_km']))
|
||||||
|
if q.rowcount:affected.add((s['vin'],s['stat_date'],s['protocol']))
|
||||||
|
# Restore valid non-import GB32960 projections that were hidden by migrated rows.
|
||||||
|
q.execute("""SELECT DISTINCT s.vin,s.stat_date,s.protocol FROM vehicle_daily_mileage_source s
|
||||||
|
JOIN vehicle_daily_mileage_source bad ON bad.vin=s.vin AND bad.stat_date=s.stat_date AND bad.protocol=s.protocol
|
||||||
|
WHERE bad.source_ip IN """+legacy+" AND s.source_ip NOT IN "+legacy+" AND s.quality_status='OK' AND s.latest_total_mileage_km IS NOT NULL")
|
||||||
|
affected.update((r['vin'],r['stat_date'],r['protocol']) for r in q.fetchall())
|
||||||
|
report['reproject_days']=len(affected)
|
||||||
|
# The Go projection utility is run before resuming writers, using canonical SQL.
|
||||||
|
(root/'reproject.json').write_text(json.dumps([{'vin':v,'date':str(d),'protocol':p} for v,d,p in sorted(affected)]))
|
||||||
|
c.commit()
|
||||||
|
(root if args.apply else pathlib.Path('/tmp')).joinpath('repair-result.json' if args.apply else 'mileage-repair-dry-run.json').write_text(json.dumps(report,default=str,indent=2))
|
||||||
|
print(json.dumps(report,default=str),flush=True)
|
||||||
|
c.close()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Run a specified maintenance binary without exposing environment credentials."""
|
||||||
|
import os,pathlib,subprocess,sys
|
||||||
|
for name in ['base.env','stat-writer.env']:
|
||||||
|
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
|
||||||
|
if '=' in line and not line.startswith('#'):
|
||||||
|
k,v=line.split('=',1);os.environ[k]=v.strip().strip(chr(34)).strip(chr(39))
|
||||||
|
sys.exit(subprocess.call(sys.argv[1:]))
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const inputPath =
|
||||||
|
"/Users/lingniu/Downloads/智能管车_车辆里程日报-105652096.xlsx";
|
||||||
|
const input = await FileBlob.load(inputPath);
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(input);
|
||||||
|
|
||||||
|
const summary = await workbook.inspect({
|
||||||
|
kind: "workbook,sheet,table",
|
||||||
|
maxChars: 12000,
|
||||||
|
tableMaxRows: 8,
|
||||||
|
tableMaxCols: 40,
|
||||||
|
tableMaxCellChars: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheet = workbook.worksheets.getItemAt(0);
|
||||||
|
const usedRange = sheet.getUsedRange(true);
|
||||||
|
const rowCount = usedRange?.rowCount ?? 0;
|
||||||
|
const columnCount = usedRange?.columnCount ?? 0;
|
||||||
|
const values = usedRange?.values ?? [];
|
||||||
|
const headers = values[0] ?? [];
|
||||||
|
const dateStartIndex = 3;
|
||||||
|
const dateEndIndex = Math.max(dateStartIndex, headers.indexOf("运行时长"));
|
||||||
|
let positiveDailyCellCount = 0;
|
||||||
|
let nonZeroVehicleCount = 0;
|
||||||
|
let noteVehicleCount = 0;
|
||||||
|
let mismatchVehicleCount = 0;
|
||||||
|
let totalDailyKm = 0;
|
||||||
|
let totalColumnKm = 0;
|
||||||
|
const nonZeroSamples = [];
|
||||||
|
const noteSamples = [];
|
||||||
|
for (const row of values.slice(1)) {
|
||||||
|
const dailyValues = row
|
||||||
|
.slice(dateStartIndex, dateEndIndex)
|
||||||
|
.map(numberValue);
|
||||||
|
const positiveValues = dailyValues.filter((value) => value > 0);
|
||||||
|
const rowDailyKm = dailyValues.reduce((sum, value) => sum + value, 0);
|
||||||
|
const rowTotalKm = numberValue(row[2]);
|
||||||
|
const note = row[dateEndIndex + 1];
|
||||||
|
positiveDailyCellCount += positiveValues.length;
|
||||||
|
totalDailyKm += rowDailyKm;
|
||||||
|
totalColumnKm += rowTotalKm;
|
||||||
|
if (positiveValues.length > 0) {
|
||||||
|
nonZeroVehicleCount += 1;
|
||||||
|
if (nonZeroSamples.length < 20) {
|
||||||
|
nonZeroSamples.push({
|
||||||
|
plate: row[0],
|
||||||
|
organization: row[1],
|
||||||
|
totalKm: rowTotalKm,
|
||||||
|
dailyKm: rowDailyKm,
|
||||||
|
positiveDayCount: positiveValues.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (note !== null && note !== undefined && String(note).trim() !== "") {
|
||||||
|
noteVehicleCount += 1;
|
||||||
|
if (noteSamples.length < 20) {
|
||||||
|
noteSamples.push({ plate: row[0], note });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Math.abs(rowTotalKm - rowDailyKm) > 0.011) {
|
||||||
|
mismatchVehicleCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inspectRange = rowCount > 0 && columnCount > 0
|
||||||
|
? `A1:${columnName(Math.min(columnCount, 40))}${Math.min(rowCount, 15)}`
|
||||||
|
: "A1:A1";
|
||||||
|
const preview = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
sheetId: sheet.name,
|
||||||
|
range: inspectRange,
|
||||||
|
include: "values,formulas",
|
||||||
|
maxChars: 25000,
|
||||||
|
tableMaxRows: 15,
|
||||||
|
tableMaxCols: 40,
|
||||||
|
tableMaxCellChars: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
inputPath,
|
||||||
|
sheetName: sheet.name,
|
||||||
|
rowCount,
|
||||||
|
columnCount,
|
||||||
|
dateColumnCount: Math.max(0, dateEndIndex - dateStartIndex),
|
||||||
|
positiveDailyCellCount,
|
||||||
|
nonZeroVehicleCount,
|
||||||
|
noteVehicleCount,
|
||||||
|
mismatchVehicleCount,
|
||||||
|
totalDailyKm: round(totalDailyKm),
|
||||||
|
totalColumnKm: round(totalColumnKm),
|
||||||
|
nonZeroSamples,
|
||||||
|
noteSamples,
|
||||||
|
summary: summary.ndjson,
|
||||||
|
preview: preview.ndjson,
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
function columnName(columnCount) {
|
||||||
|
let value = columnCount;
|
||||||
|
let name = "";
|
||||||
|
while (value > 0) {
|
||||||
|
value -= 1;
|
||||||
|
name = String.fromCharCode(65 + (value % 26)) + name;
|
||||||
|
value = Math.floor(value / 26);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
return Math.round(value * 1000) / 1000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,741 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import fsSync from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import {
|
||||||
|
FileBlob,
|
||||||
|
SpreadsheetFile,
|
||||||
|
Workbook,
|
||||||
|
} from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const root =
|
||||||
|
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
|
||||||
|
const workDir = path.join(root, "outputs/g7-mileage-history-20260724");
|
||||||
|
const dailyDir = path.join(workDir, "raw/daily");
|
||||||
|
const statisticDir = path.join(workDir, "raw/statistic");
|
||||||
|
const mergedDir = path.join(workDir, "merged");
|
||||||
|
const outputXlsx = path.join(
|
||||||
|
mergedDir,
|
||||||
|
"G7车辆每日里程及里程统计_20220101-20260720.xlsx",
|
||||||
|
);
|
||||||
|
const importCsv = path.join(
|
||||||
|
mergedDir,
|
||||||
|
"G7车辆每日里程_20220101-20260720.csv",
|
||||||
|
);
|
||||||
|
const manifestPath = path.join(mergedDir, "manifest.json");
|
||||||
|
const priorMappingCsv = path.join(
|
||||||
|
root,
|
||||||
|
"outputs/g7-mileage-import-20260716/import_rows.csv",
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.mkdir(mergedDir, { recursive: true });
|
||||||
|
|
||||||
|
const months = buildMonths();
|
||||||
|
const dailyFilesByTaskId = await sortedXlsxFiles(dailyDir);
|
||||||
|
// 2022-02 was the one-file validation task. The remaining formal queue then
|
||||||
|
// started with 2022-01 and continued from 2022-03, so swap the first two task IDs
|
||||||
|
// back into natural month order.
|
||||||
|
const dailyFiles = [
|
||||||
|
dailyFilesByTaskId[1],
|
||||||
|
dailyFilesByTaskId[0],
|
||||||
|
...dailyFilesByTaskId.slice(2),
|
||||||
|
];
|
||||||
|
const statisticFiles = await sortedXlsxFiles(statisticDir);
|
||||||
|
assert(dailyFiles.length === months.length, `日报文件应为 ${months.length},实际 ${dailyFiles.length}`);
|
||||||
|
assert(
|
||||||
|
statisticFiles.length === months.length,
|
||||||
|
`里程统计文件应为 ${months.length},实际 ${statisticFiles.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const vehicles = new Map();
|
||||||
|
const dateOrder = [];
|
||||||
|
const dateSource = new Map();
|
||||||
|
const monthlyDailyTotals = new Map();
|
||||||
|
const dailyBatchManifest = [];
|
||||||
|
let dailyPositiveCells = 0;
|
||||||
|
let dailyZeroCells = 0;
|
||||||
|
let dailyTotalKm = 0;
|
||||||
|
let dailyTotalMismatchRows = 0;
|
||||||
|
let dailyCompletenessNoteRows = 0;
|
||||||
|
|
||||||
|
for (let monthIndex = 0; monthIndex < months.length; monthIndex += 1) {
|
||||||
|
const month = months[monthIndex];
|
||||||
|
const file = dailyFiles[monthIndex];
|
||||||
|
const values = await readFirstSheetValues(file);
|
||||||
|
const headers = values[0] ?? [];
|
||||||
|
const runtimeIndex = headers.indexOf("运行时长");
|
||||||
|
assert(runtimeIndex > 3, `${path.basename(file)} 缺少运行时长列`);
|
||||||
|
const dateColumnCount = runtimeIndex - 3;
|
||||||
|
assert(
|
||||||
|
dateColumnCount === month.dayCount,
|
||||||
|
`${path.basename(file)} 日期列应为 ${month.dayCount},实际 ${dateColumnCount}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthDates = [];
|
||||||
|
for (let day = 1; day <= month.dayCount; day += 1) {
|
||||||
|
const date = `${month.year}-${pad(month.month)}-${pad(day)}`;
|
||||||
|
monthDates.push(date);
|
||||||
|
dateOrder.push(date);
|
||||||
|
dateSource.set(date, path.basename(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
let batchTotal = 0;
|
||||||
|
let batchPositive = 0;
|
||||||
|
let batchZero = 0;
|
||||||
|
let batchMismatch = 0;
|
||||||
|
let batchNotes = 0;
|
||||||
|
const seenPlates = new Set();
|
||||||
|
for (let rowIndex = 1; rowIndex < values.length; rowIndex += 1) {
|
||||||
|
const row = values[rowIndex] ?? [];
|
||||||
|
const plate = cleanText(row[0]);
|
||||||
|
if (!plate) continue;
|
||||||
|
assert(!seenPlates.has(plate), `${path.basename(file)} 存在重复车牌 ${plate}`);
|
||||||
|
seenPlates.add(plate);
|
||||||
|
const organization = cleanText(row[1]);
|
||||||
|
const statedTotal = numberValue(row[2]);
|
||||||
|
const dailyValues = row.slice(3, runtimeIndex).map(numberValue);
|
||||||
|
const calculatedTotal = dailyValues.reduce((sum, value) => sum + value, 0);
|
||||||
|
if (Math.abs(statedTotal - calculatedTotal) > 0.011) {
|
||||||
|
dailyTotalMismatchRows += 1;
|
||||||
|
batchMismatch += 1;
|
||||||
|
}
|
||||||
|
const note = cleanText(row[runtimeIndex + 1]);
|
||||||
|
if (note) {
|
||||||
|
dailyCompletenessNoteRows += 1;
|
||||||
|
batchNotes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vehicle = vehicles.get(plate);
|
||||||
|
if (!vehicle) {
|
||||||
|
vehicle = {
|
||||||
|
plate,
|
||||||
|
organization,
|
||||||
|
values: new Map(),
|
||||||
|
};
|
||||||
|
vehicles.set(plate, vehicle);
|
||||||
|
} else if (!vehicle.organization && organization) {
|
||||||
|
vehicle.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let dayIndex = 0; dayIndex < dailyValues.length; dayIndex += 1) {
|
||||||
|
const value = dailyValues[dayIndex];
|
||||||
|
const date = monthDates[dayIndex];
|
||||||
|
vehicle.values.set(date, value);
|
||||||
|
batchTotal += value;
|
||||||
|
dailyTotalKm += value;
|
||||||
|
if (value > 0) {
|
||||||
|
batchPositive += 1;
|
||||||
|
dailyPositiveCells += 1;
|
||||||
|
} else {
|
||||||
|
batchZero += 1;
|
||||||
|
dailyZeroCells += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monthlyDailyTotals.set(month.key, round(batchTotal));
|
||||||
|
dailyBatchManifest.push({
|
||||||
|
type: "车辆里程日报",
|
||||||
|
month: month.key,
|
||||||
|
date_from: month.dateFrom,
|
||||||
|
date_to: month.dateTo,
|
||||||
|
file: path.basename(file),
|
||||||
|
vehicle_rows: seenPlates.size,
|
||||||
|
date_columns: month.dayCount,
|
||||||
|
positive_cells: batchPositive,
|
||||||
|
zero_cells: batchZero,
|
||||||
|
total_km: round(batchTotal),
|
||||||
|
total_mismatch_rows: batchMismatch,
|
||||||
|
completeness_note_rows: batchNotes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(dateOrder.length === 1662, `日期列应为 1662,实际 ${dateOrder.length}`);
|
||||||
|
assert(new Set(dateOrder).size === dateOrder.length, "日报日期列存在重复");
|
||||||
|
assert(dateOrder[0] === "2022-01-01", `起始日期异常:${dateOrder[0]}`);
|
||||||
|
assert(dateOrder.at(-1) === "2026-07-20", `结束日期异常:${dateOrder.at(-1)}`);
|
||||||
|
|
||||||
|
const statisticRows = [];
|
||||||
|
const monthlyStatisticTotals = new Map();
|
||||||
|
const statisticBatchManifest = [];
|
||||||
|
for (let monthIndex = 0; monthIndex < months.length; monthIndex += 1) {
|
||||||
|
const month = months[monthIndex];
|
||||||
|
const file = statisticFiles[monthIndex];
|
||||||
|
const values = await readFirstSheetValues(file);
|
||||||
|
const headers = (values[0] ?? []).map(cleanText);
|
||||||
|
const plateIndex = findHeader(headers, ["车牌号"]);
|
||||||
|
const orgIndex = findHeader(headers, ["所属机构", "机构"]);
|
||||||
|
const sourceOrgIndex = findHeader(headers, ["来源机构"]);
|
||||||
|
const lengthIndex = findHeader(headers, ["车长(米)", "车长"]);
|
||||||
|
const boxIndex = findHeader(headers, ["厢型"]);
|
||||||
|
const runtimeIndex = findHeader(headers, ["运行时长"]);
|
||||||
|
const mileageIndex = findHeader(headers, ["行驶里程(KM)", "行驶里程"]);
|
||||||
|
assert(plateIndex >= 0, `${path.basename(file)} 缺少车牌号`);
|
||||||
|
assert(mileageIndex >= 0, `${path.basename(file)} 缺少行驶里程`);
|
||||||
|
|
||||||
|
let batchTotal = 0;
|
||||||
|
let vehicleRows = 0;
|
||||||
|
for (let rowIndex = 1; rowIndex < values.length; rowIndex += 1) {
|
||||||
|
const row = values[rowIndex] ?? [];
|
||||||
|
const plate = cleanText(row[plateIndex]);
|
||||||
|
if (!plate) continue;
|
||||||
|
const mileage = numberValue(row[mileageIndex]);
|
||||||
|
batchTotal += mileage;
|
||||||
|
vehicleRows += 1;
|
||||||
|
statisticRows.push([
|
||||||
|
month.key,
|
||||||
|
plate,
|
||||||
|
orgIndex >= 0 ? cleanText(row[orgIndex]) : "",
|
||||||
|
sourceOrgIndex >= 0 ? cleanText(row[sourceOrgIndex]) : "",
|
||||||
|
lengthIndex >= 0 ? row[lengthIndex] ?? "" : "",
|
||||||
|
boxIndex >= 0 ? cleanText(row[boxIndex]) : "",
|
||||||
|
runtimeIndex >= 0 ? cleanText(row[runtimeIndex]) : "",
|
||||||
|
mileage,
|
||||||
|
path.basename(file),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
monthlyStatisticTotals.set(month.key, round(batchTotal));
|
||||||
|
statisticBatchManifest.push({
|
||||||
|
type: "车辆里程统计",
|
||||||
|
month: month.key,
|
||||||
|
date_from: month.dateFrom,
|
||||||
|
date_to: month.dateTo,
|
||||||
|
file: path.basename(file),
|
||||||
|
vehicle_rows: vehicleRows,
|
||||||
|
total_km: round(batchTotal),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = await readPriorMapping(priorMappingCsv);
|
||||||
|
const sortedVehicles = [...vehicles.values()].sort((a, b) =>
|
||||||
|
a.plate.localeCompare(b.plate, "zh-CN"),
|
||||||
|
);
|
||||||
|
const mappedVehicles = sortedVehicles.filter((vehicle) => mapping.has(vehicle.plate));
|
||||||
|
const unmappedVehicles = sortedVehicles.filter((vehicle) => !mapping.has(vehicle.plate));
|
||||||
|
|
||||||
|
await writeImportCsv(importCsv, sortedVehicles, dateOrder);
|
||||||
|
|
||||||
|
const workbook = Workbook.create();
|
||||||
|
const summarySheet = workbook.worksheets.add("汇总");
|
||||||
|
const positiveDailySheet = workbook.worksheets.add("每日里程非零");
|
||||||
|
const statisticSheet = workbook.worksheets.add("G7里程统计");
|
||||||
|
const mappingSheet = workbook.worksheets.add("车辆映射");
|
||||||
|
const batchSheet = workbook.worksheets.add("导出批次");
|
||||||
|
|
||||||
|
buildSummarySheet(summarySheet);
|
||||||
|
await buildPositiveDailySheet(positiveDailySheet);
|
||||||
|
await buildStatisticSheet(statisticSheet);
|
||||||
|
await buildMappingSheet(mappingSheet);
|
||||||
|
await buildBatchSheet(batchSheet);
|
||||||
|
|
||||||
|
const summaryInspect = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
sheetId: "汇总",
|
||||||
|
range: "A1:F25",
|
||||||
|
include: "values,formulas",
|
||||||
|
maxChars: 12000,
|
||||||
|
tableMaxRows: 25,
|
||||||
|
tableMaxCols: 6,
|
||||||
|
});
|
||||||
|
const dailyInspect = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
sheetId: "每日里程非零",
|
||||||
|
range: "A1:D12",
|
||||||
|
include: "values,formulas",
|
||||||
|
maxChars: 12000,
|
||||||
|
tableMaxRows: 12,
|
||||||
|
tableMaxCols: 4,
|
||||||
|
});
|
||||||
|
const formulaErrors = await workbook.inspect({
|
||||||
|
kind: "match",
|
||||||
|
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
|
||||||
|
options: { useRegex: true, maxResults: 300 },
|
||||||
|
summary: "final formula error scan",
|
||||||
|
});
|
||||||
|
assert(!formulaErrors.ndjson.includes('"match"'), "工作簿存在公式错误");
|
||||||
|
|
||||||
|
const summaryPreview = await workbook.render({
|
||||||
|
sheetName: "汇总",
|
||||||
|
range: "A1:F25",
|
||||||
|
scale: 1.5,
|
||||||
|
format: "png",
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(mergedDir, "summary-preview.png"),
|
||||||
|
new Uint8Array(await summaryPreview.arrayBuffer()),
|
||||||
|
);
|
||||||
|
const dailyPreview = await workbook.render({
|
||||||
|
sheetName: "每日里程非零",
|
||||||
|
range: "A1:D12",
|
||||||
|
scale: 1.2,
|
||||||
|
format: "png",
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(mergedDir, "daily-preview.png"),
|
||||||
|
new Uint8Array(await dailyPreview.arrayBuffer()),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await SpreadsheetFile.exportXlsx(workbook);
|
||||||
|
await output.save(outputXlsx);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
stage: "export_xlsx",
|
||||||
|
name: error?.name,
|
||||||
|
message: error?.message,
|
||||||
|
stack: String(error?.stack ?? "")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.includes("artifact_tool.mjs:3121"))
|
||||||
|
.slice(0, 12),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyReconciliation = months.map((month) => {
|
||||||
|
const dailyKm = monthlyDailyTotals.get(month.key) ?? 0;
|
||||||
|
const statisticKm = monthlyStatisticTotals.get(month.key) ?? 0;
|
||||||
|
return {
|
||||||
|
month: month.key,
|
||||||
|
daily_km: dailyKm,
|
||||||
|
statistic_km: statisticKm,
|
||||||
|
difference_km: round(dailyKm - statisticKm),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const manifest = {
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
range: {
|
||||||
|
date_from: dateOrder[0],
|
||||||
|
date_to: dateOrder.at(-1),
|
||||||
|
days: dateOrder.length,
|
||||||
|
months: months.length,
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
daily_raw: dailyFiles.map((file) => path.basename(file)),
|
||||||
|
statistic_raw: statisticFiles.map((file) => path.basename(file)),
|
||||||
|
workbook: path.basename(outputXlsx),
|
||||||
|
import_csv: path.basename(importCsv),
|
||||||
|
},
|
||||||
|
vehicle_count: sortedVehicles.length,
|
||||||
|
mapped_vehicle_count: mappedVehicles.length,
|
||||||
|
unmapped_vehicle_count: unmappedVehicles.length,
|
||||||
|
vehicle_day_rows: sortedVehicles.length * dateOrder.length,
|
||||||
|
mapped_vehicle_day_rows: mappedVehicles.length * dateOrder.length,
|
||||||
|
positive_cells: dailyPositiveCells,
|
||||||
|
zero_cells: dailyZeroCells,
|
||||||
|
daily_total_km: round(dailyTotalKm),
|
||||||
|
daily_total_mismatch_rows: dailyTotalMismatchRows,
|
||||||
|
completeness_note_rows: dailyCompletenessNoteRows,
|
||||||
|
monthly_reconciliation: monthlyReconciliation,
|
||||||
|
daily_batches: dailyBatchManifest,
|
||||||
|
statistic_batches: statisticBatchManifest,
|
||||||
|
verification: {
|
||||||
|
summary_inspect: summaryInspect.ndjson,
|
||||||
|
daily_inspect: dailyInspect.ndjson,
|
||||||
|
formula_errors: formulaErrors.ndjson,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
outputXlsx,
|
||||||
|
importCsv,
|
||||||
|
manifestPath,
|
||||||
|
vehicleCount: sortedVehicles.length,
|
||||||
|
mappedVehicleCount: mappedVehicles.length,
|
||||||
|
unmappedVehicleCount: unmappedVehicles.length,
|
||||||
|
dateCount: dateOrder.length,
|
||||||
|
vehicleDayRows: sortedVehicles.length * dateOrder.length,
|
||||||
|
mappedVehicleDayRows: mappedVehicles.length * dateOrder.length,
|
||||||
|
positiveCells: dailyPositiveCells,
|
||||||
|
zeroCells: dailyZeroCells,
|
||||||
|
totalKm: round(dailyTotalKm),
|
||||||
|
monthlyDifferenceCount: monthlyReconciliation.filter(
|
||||||
|
(row) => Math.abs(row.difference_km) > 0.02,
|
||||||
|
).length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildSummarySheet(sheet) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
sheet.getRange("A1:F1").merge();
|
||||||
|
sheet.getRange("A1").values = [["G7 车辆里程全量导出汇总"]];
|
||||||
|
sheet.getRange("A1:F1").format = {
|
||||||
|
fill: "#0F766E",
|
||||||
|
font: { bold: true, color: "#FFFFFF", size: 16 },
|
||||||
|
horizontalAlignment: "center",
|
||||||
|
verticalAlignment: "center",
|
||||||
|
};
|
||||||
|
sheet.getRange("A1:F1").format.rowHeight = 30;
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
["指标", "值", "说明"],
|
||||||
|
["统计起始日期", new Date(`${dateOrder[0]}T00:00:00+08:00`), "G7 日报"],
|
||||||
|
["统计结束日期", new Date(`${dateOrder.at(-1)}T00:00:00+08:00`), "含当日"],
|
||||||
|
["自然日数", dateOrder.length, "连续日期"],
|
||||||
|
["日报导出批次", dailyFiles.length, "每月 1 份"],
|
||||||
|
["里程统计导出批次", statisticFiles.length, "每月 1 份"],
|
||||||
|
["G7 车辆数", sortedVehicles.length, "当前导出车辆列表"],
|
||||||
|
["已映射 VIN 车辆数", mappedVehicles.length, "沿用现有车辆映射"],
|
||||||
|
["未映射车辆数", unmappedVehicles.length, "不进入正式导入"],
|
||||||
|
["映射覆盖率", null, "已映射 / G7 车辆数"],
|
||||||
|
["车辆-日期单元格", sortedVehicles.length * dateOrder.length, "包含 0 km"],
|
||||||
|
["可导入车辆-日期行", mappedVehicles.length * dateOrder.length, "包含 0 km"],
|
||||||
|
["正里程单元格", dailyPositiveCells, "> 0 km"],
|
||||||
|
["0 km 单元格", dailyZeroCells, "按用户确认保留"],
|
||||||
|
["日报累计里程(km)", round(dailyTotalKm), "55 份日报求和"],
|
||||||
|
["日报合计不一致车辆月", dailyTotalMismatchRows, "日报“行驶里程”与逐日求和"],
|
||||||
|
["完整度说明非空车辆月", dailyCompletenessNoteRows, "来自 G7 日报"],
|
||||||
|
];
|
||||||
|
sheet.getRangeByIndexes(2, 0, metrics.length, 3).values = metrics;
|
||||||
|
sheet.getRange("A3:C3").format = headerFormat();
|
||||||
|
sheet.getRange("B4:B5").format.numberFormat = "yyyy-mm-dd";
|
||||||
|
sheet.getRange("B6:B19").format.numberFormat = "#,##0.00";
|
||||||
|
sheet.getRange("B12").formulas = [["=IFERROR(B10/B9,0)"]];
|
||||||
|
sheet.getRange("B12").format.numberFormat = "0.0%";
|
||||||
|
sheet.getRange("A4:A19").format.font = { bold: true, color: "#134E4A" };
|
||||||
|
sheet.getRange("A3:C19").format.borders = {
|
||||||
|
preset: "inside",
|
||||||
|
style: "thin",
|
||||||
|
color: "#D1D5DB",
|
||||||
|
};
|
||||||
|
|
||||||
|
sheet.getRange("E3:H3").values = [["月份", "日报合计(km)", "里程统计合计(km)", "差异(km)"]];
|
||||||
|
sheet.getRange("E3:H3").format = headerFormat();
|
||||||
|
const monthlyRows = months.map((month) => [
|
||||||
|
month.key,
|
||||||
|
monthlyDailyTotals.get(month.key) ?? 0,
|
||||||
|
monthlyStatisticTotals.get(month.key) ?? 0,
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
sheet.getRangeByIndexes(3, 4, monthlyRows.length, 4).values = monthlyRows;
|
||||||
|
sheet.getRange("H4").formulas = [["=F4-G4"]];
|
||||||
|
sheet.getRange(`H4:H${3 + monthlyRows.length}`).fillDown();
|
||||||
|
sheet.getRange(`F4:H${3 + monthlyRows.length}`).format.numberFormat = "#,##0.00";
|
||||||
|
sheet.getRange(`E3:H${3 + monthlyRows.length}`).format.borders = {
|
||||||
|
preset: "inside",
|
||||||
|
style: "thin",
|
||||||
|
color: "#E5E7EB",
|
||||||
|
};
|
||||||
|
|
||||||
|
sheet.freezePanes.freezeRows(3);
|
||||||
|
sheet.getRange("A21:C23").merge();
|
||||||
|
sheet.getRange("A21").values = [[
|
||||||
|
"Excel 的“每日里程非零”工作表列出全部正里程记录;未出现的车辆×日期组合按用户确认均为 0 km。含全部 0 km 的逐行明细保存在同目录 CSV:G7车辆每日里程_20220101-20260720.csv。",
|
||||||
|
]];
|
||||||
|
sheet.getRange("A21:C23").format = {
|
||||||
|
fill: "#ECFDF5",
|
||||||
|
font: { color: "#065F46", italic: true },
|
||||||
|
wrapText: true,
|
||||||
|
verticalAlignment: "center",
|
||||||
|
};
|
||||||
|
sheet.getRange(`A1:H${3 + monthlyRows.length}`).format.font = { name: "Aptos", size: 10 };
|
||||||
|
sheet.getRange(`A1:A${3 + monthlyRows.length}`).format.columnWidth = 24;
|
||||||
|
sheet.getRange(`B1:B${3 + monthlyRows.length}`).format.columnWidth = 18;
|
||||||
|
sheet.getRange(`C1:C${3 + monthlyRows.length}`).format.columnWidth = 30;
|
||||||
|
sheet.getRange(`D1:D${3 + monthlyRows.length}`).format.columnWidth = 3;
|
||||||
|
sheet.getRange(`E1:E${3 + monthlyRows.length}`).format.columnWidth = 12;
|
||||||
|
sheet.getRange(`F1:H${3 + monthlyRows.length}`).format.columnWidth = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPositiveDailySheet(sheet) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
const headers = ["日期", "车牌号", "机构", "每日里程(km)"];
|
||||||
|
sheet.getRange("A1:D1").values = [headers];
|
||||||
|
sheet.getRange("A1:D1").format = headerFormat();
|
||||||
|
const rows = [];
|
||||||
|
for (const vehicle of sortedVehicles) {
|
||||||
|
for (const date of dateOrder) {
|
||||||
|
const mileage = vehicle.values.get(date) ?? 0;
|
||||||
|
if (mileage <= 0) continue;
|
||||||
|
rows.push([
|
||||||
|
new Date(`${date}T00:00:00+08:00`),
|
||||||
|
vehicle.plate,
|
||||||
|
vehicle.organization,
|
||||||
|
mileage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chunkSize = 1000;
|
||||||
|
for (let start = 0; start < rows.length; start += chunkSize) {
|
||||||
|
const chunk = rows.slice(start, start + chunkSize);
|
||||||
|
sheet.getRangeByIndexes(start + 1, 0, chunk.length, headers.length).values = chunk;
|
||||||
|
}
|
||||||
|
sheet.getRange(`A2:A${rows.length + 1}`).format.numberFormat = "yyyy-mm-dd";
|
||||||
|
sheet.getRange(`D2:D${rows.length + 1}`).format.numberFormat = "#,##0.00";
|
||||||
|
sheet.freezePanes.freezeRows(1);
|
||||||
|
[14, 16, 30, 18].forEach((width, index) => {
|
||||||
|
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildStatisticSheet(sheet) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
const headers = [
|
||||||
|
"月份",
|
||||||
|
"车牌号",
|
||||||
|
"所属机构",
|
||||||
|
"来源机构",
|
||||||
|
"车长(米)",
|
||||||
|
"厢型",
|
||||||
|
"运行时长",
|
||||||
|
"行驶里程(KM)",
|
||||||
|
"来源文件",
|
||||||
|
];
|
||||||
|
sheet.getRange("A1:I1").values = [headers];
|
||||||
|
sheet.getRange("A1:I1").format = headerFormat();
|
||||||
|
const chunkSize = 1000;
|
||||||
|
for (let start = 0; start < statisticRows.length; start += chunkSize) {
|
||||||
|
const chunk = statisticRows.slice(start, start + chunkSize);
|
||||||
|
sheet.getRangeByIndexes(start + 1, 0, chunk.length, headers.length).values = chunk;
|
||||||
|
}
|
||||||
|
sheet.getRange(`E2:E${statisticRows.length + 1}`).format.numberFormat = "0.0";
|
||||||
|
sheet.getRange(`H2:H${statisticRows.length + 1}`).format.numberFormat = "#,##0.00";
|
||||||
|
sheet.freezePanes.freezeRows(1);
|
||||||
|
const widths = [12, 16, 28, 28, 12, 16, 18, 18, 34];
|
||||||
|
widths.forEach((width, index) => {
|
||||||
|
sheet.getRangeByIndexes(0, index, statisticRows.length + 1, 1).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildMappingSheet(sheet) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
const headers = ["车牌号", "机构", "VIN", "终端号", "映射状态", "期间里程(km)", "正里程天数", "0 km天数"];
|
||||||
|
sheet.getRange("A1:H1").values = [headers];
|
||||||
|
sheet.getRange("A1:H1").format = headerFormat();
|
||||||
|
const rows = sortedVehicles.map((vehicle) => {
|
||||||
|
const mapped = mapping.get(vehicle.plate);
|
||||||
|
const allValues = dateOrder.map((date) => vehicle.values.get(date) ?? 0);
|
||||||
|
const positiveDays = allValues.filter((value) => value > 0).length;
|
||||||
|
return [
|
||||||
|
vehicle.plate,
|
||||||
|
vehicle.organization,
|
||||||
|
mapped?.vin ?? "",
|
||||||
|
mapped?.phone ?? "",
|
||||||
|
mapped ? "已映射" : "未映射",
|
||||||
|
round(allValues.reduce((sum, value) => sum + value, 0)),
|
||||||
|
positiveDays,
|
||||||
|
dateOrder.length - positiveDays,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
sheet.getRangeByIndexes(1, 0, rows.length, headers.length).values = rows;
|
||||||
|
sheet.getRange(`F2:F${rows.length + 1}`).format.numberFormat = "#,##0.00";
|
||||||
|
sheet.getRange(`E2:E${rows.length + 1}`).conditionalFormats.add("containsText", {
|
||||||
|
text: "未映射",
|
||||||
|
format: { fill: "#FEE2E2", font: { color: "#991B1B", bold: true } },
|
||||||
|
});
|
||||||
|
sheet.freezePanes.freezeRows(1);
|
||||||
|
[16, 30, 22, 18, 14, 18, 14, 14].forEach((width, index) => {
|
||||||
|
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBatchSheet(sheet) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
const headers = [
|
||||||
|
"类型",
|
||||||
|
"月份",
|
||||||
|
"开始日期",
|
||||||
|
"结束日期",
|
||||||
|
"来源文件",
|
||||||
|
"车辆数",
|
||||||
|
"日期列数",
|
||||||
|
"正里程单元格",
|
||||||
|
"0 km 单元格",
|
||||||
|
"合计里程(km)",
|
||||||
|
];
|
||||||
|
sheet.getRange("A1:J1").values = [headers];
|
||||||
|
sheet.getRange("A1:J1").format = headerFormat();
|
||||||
|
const rows = [
|
||||||
|
...dailyBatchManifest.map((batch) => [
|
||||||
|
batch.type,
|
||||||
|
batch.month,
|
||||||
|
new Date(`${batch.date_from}T00:00:00+08:00`),
|
||||||
|
new Date(`${batch.date_to}T00:00:00+08:00`),
|
||||||
|
batch.file,
|
||||||
|
batch.vehicle_rows,
|
||||||
|
batch.date_columns,
|
||||||
|
batch.positive_cells,
|
||||||
|
batch.zero_cells,
|
||||||
|
batch.total_km,
|
||||||
|
]),
|
||||||
|
...statisticBatchManifest.map((batch) => [
|
||||||
|
batch.type,
|
||||||
|
batch.month,
|
||||||
|
new Date(`${batch.date_from}T00:00:00+08:00`),
|
||||||
|
new Date(`${batch.date_to}T00:00:00+08:00`),
|
||||||
|
batch.file,
|
||||||
|
batch.vehicle_rows,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
batch.total_km,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
sheet.getRangeByIndexes(1, 0, rows.length, headers.length).values = rows;
|
||||||
|
sheet.getRange(`C2:D${rows.length + 1}`).format.numberFormat = "yyyy-mm-dd";
|
||||||
|
sheet.getRange(`F2:J${rows.length + 1}`).format.numberFormat = "#,##0.00";
|
||||||
|
sheet.freezePanes.freezeRows(1);
|
||||||
|
[18, 12, 14, 14, 36, 12, 12, 18, 18, 18].forEach((width, index) => {
|
||||||
|
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFirstSheetValues(file) {
|
||||||
|
const input = await FileBlob.load(file);
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(input);
|
||||||
|
const sheet = workbook.worksheets.getItemAt(0);
|
||||||
|
const usedRange = sheet.getUsedRange(true);
|
||||||
|
return usedRange?.values ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sortedXlsxFiles(directory) {
|
||||||
|
const names = (await fs.readdir(directory))
|
||||||
|
.filter((name) => name.endsWith(".xlsx"))
|
||||||
|
.sort((a, b) => extractTaskId(a) - extractTaskId(b));
|
||||||
|
return names.map((name) => path.join(directory, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTaskId(file) {
|
||||||
|
const match = path.basename(file).match(/-(\d+)\.xlsx$/);
|
||||||
|
assert(match, `无法从文件名解析任务号:${file}`);
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonths() {
|
||||||
|
const result = [];
|
||||||
|
for (let year = 2022, month = 1; year < 2026 || (year === 2026 && month <= 7); ) {
|
||||||
|
const naturalLastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||||
|
const lastDay = year === 2026 && month === 7 ? 20 : naturalLastDay;
|
||||||
|
result.push({
|
||||||
|
key: `${year}-${pad(month)}`,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
dayCount: lastDay,
|
||||||
|
dateFrom: `${year}-${pad(month)}-01`,
|
||||||
|
dateTo: `${year}-${pad(month)}-${pad(lastDay)}`,
|
||||||
|
});
|
||||||
|
month += 1;
|
||||||
|
if (month === 13) {
|
||||||
|
year += 1;
|
||||||
|
month = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPriorMapping(csvPath) {
|
||||||
|
const text = await fs.readFile(csvPath, "utf8");
|
||||||
|
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
||||||
|
const result = new Map();
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
if (!line) continue;
|
||||||
|
const columns = parseCsvLine(line);
|
||||||
|
const [vin, plate, phone] = columns;
|
||||||
|
if (!vin || !plate) continue;
|
||||||
|
const current = result.get(plate);
|
||||||
|
if (current && current.vin !== vin) {
|
||||||
|
result.delete(plate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.set(plate, { vin, phone: phone ?? "" });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeImportCsv(file, vehicleRows, dates) {
|
||||||
|
const stream = fsSync.createWriteStream(file, { encoding: "utf8" });
|
||||||
|
stream.write("\uFEFFplate,date,daily_mileage_km\n");
|
||||||
|
for (const vehicle of vehicleRows) {
|
||||||
|
for (const date of dates) {
|
||||||
|
const value = vehicle.values.get(date) ?? 0;
|
||||||
|
if (!stream.write(`${csvCell(vehicle.plate)},${date},${numberText(value)}\n`)) {
|
||||||
|
await once(stream, "drain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.end();
|
||||||
|
await once(stream, "finish");
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerFormat() {
|
||||||
|
return {
|
||||||
|
fill: "#0F766E",
|
||||||
|
font: { bold: true, color: "#FFFFFF" },
|
||||||
|
horizontalAlignment: "center",
|
||||||
|
verticalAlignment: "center",
|
||||||
|
wrapText: true,
|
||||||
|
borders: { preset: "outside", style: "thin", color: "#115E59" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeader(headers, names) {
|
||||||
|
return headers.findIndex((header) => names.includes(header));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const result = [];
|
||||||
|
let value = "";
|
||||||
|
let quoted = false;
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const char = line[index];
|
||||||
|
if (quoted) {
|
||||||
|
if (char === '"' && line[index + 1] === '"') {
|
||||||
|
value += '"';
|
||||||
|
index += 1;
|
||||||
|
} else if (char === '"') {
|
||||||
|
quoted = false;
|
||||||
|
} else {
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
} else if (char === '"') {
|
||||||
|
quoted = true;
|
||||||
|
} else if (char === ",") {
|
||||||
|
result.push(value);
|
||||||
|
value = "";
|
||||||
|
} else {
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value) {
|
||||||
|
const text = cleanText(value);
|
||||||
|
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return value === null || value === undefined ? "" : String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberText(value) {
|
||||||
|
return String(round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysql "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
g7Protocol = "JT808"
|
||||||
|
g7SourceID = int64(661200)
|
||||||
|
g7SourceIP = "manual-g7s-excel"
|
||||||
|
g7SourceCode = "g7s_excel"
|
||||||
|
g7PlatformName = "G7s Excel Override"
|
||||||
|
g7QualityReason = "manual_g7s_gps_quarterly_import_20260101_20260630_20260728"
|
||||||
|
dateFrom = "2026-01-01"
|
||||||
|
dateTo = "2026-06-30"
|
||||||
|
expectedDays = 181
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapping struct {
|
||||||
|
VIN string
|
||||||
|
Ambiguous bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceStats struct {
|
||||||
|
Rows int64 `json:"rows"`
|
||||||
|
Vehicles int `json:"vehicles"`
|
||||||
|
Dates int `json:"dates"`
|
||||||
|
Positive int64 `json:"positive_rows"`
|
||||||
|
Zero int64 `json:"zero_rows"`
|
||||||
|
TotalKM float64 `json:"total_km"`
|
||||||
|
MinDate string `json:"min_date"`
|
||||||
|
MaxDate string `json:"max_date"`
|
||||||
|
PlateCount int `json:"plate_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type stageStats struct {
|
||||||
|
Rows int64 `json:"rows"`
|
||||||
|
Vehicles int `json:"vehicles"`
|
||||||
|
Dates int `json:"dates"`
|
||||||
|
Positive int64 `json:"positive_rows"`
|
||||||
|
Zero int64 `json:"zero_rows"`
|
||||||
|
TotalKM float64 `json:"total_km"`
|
||||||
|
MappedPlates int `json:"mapped_plates"`
|
||||||
|
UnmappedPlates int `json:"unmapped_plates"`
|
||||||
|
AmbiguousPlates int `json:"ambiguous_plates"`
|
||||||
|
VINCollisionPlates int `json:"vin_collision_plates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type existingStats struct {
|
||||||
|
ExistingFinalRows int64 `json:"existing_final_rows"`
|
||||||
|
NewFinalRows int64 `json:"new_final_rows"`
|
||||||
|
ChangedExistingFinalRows int64 `json:"changed_existing_final_rows"`
|
||||||
|
UnchangedExistingFinalRows int64 `json:"unchanged_existing_final_rows"`
|
||||||
|
OtherSourceFinalRows int64 `json:"other_source_final_rows"`
|
||||||
|
OtherSelectedCandidateRows int64 `json:"other_selected_candidate_rows"`
|
||||||
|
ExistingFinalTotalKM float64 `json:"existing_final_total_km"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
CompletedAt string `json:"completed_at"`
|
||||||
|
Source sourceStats `json:"source"`
|
||||||
|
Stage stageStats `json:"stage"`
|
||||||
|
Existing existingStats `json:"existing"`
|
||||||
|
UnmappedPlates []string `json:"unmapped_plates"`
|
||||||
|
AmbiguousPlates []string `json:"ambiguous_plates"`
|
||||||
|
CollisionPlates []string `json:"vin_collision_plates"`
|
||||||
|
BackupFinal string `json:"backup_final,omitempty"`
|
||||||
|
BackupCandidates string `json:"backup_candidates,omitempty"`
|
||||||
|
FinalMatchedRows int64 `json:"final_matched_rows"`
|
||||||
|
FinalTotalKM float64 `json:"final_total_km"`
|
||||||
|
CandidateMatchedRows int64 `json:"candidate_matched_rows"`
|
||||||
|
OtherSelectedRows int64 `json:"other_selected_rows"`
|
||||||
|
SourceID int64 `json:"source_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
var inputPath, mappingPath, dsn, backupDir string
|
||||||
|
var apply bool
|
||||||
|
var timeout time.Duration
|
||||||
|
flag.StringVar(&inputPath, "input", "", "normalized CSV or CSV.gz")
|
||||||
|
flag.StringVar(&mappingPath, "mapping-csv", "", "optional existing vin,plate mapping CSV")
|
||||||
|
flag.StringVar(&dsn, "mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "MySQL DSN")
|
||||||
|
flag.StringVar(&backupDir, "backup-dir", "/tmp/g7-gps-quarterly-backup-20260728", "backup directory for apply")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "apply changes; default is dry-run")
|
||||||
|
flag.DurationVar(&timeout, "timeout", 30*time.Minute, "overall timeout")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if strings.TrimSpace(inputPath) == "" {
|
||||||
|
return errors.New("-input is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(dsn) == "" {
|
||||||
|
return errors.New("MYSQL_DSN or -mysql-dsn is required")
|
||||||
|
}
|
||||||
|
normalized, err := normalizedDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
db, err := sql.Open("mysql", normalized)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
db.SetMaxOpenConns(2)
|
||||||
|
db.SetMaxIdleConns(2)
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
source, inputPlates, err := scanInput(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if source.MinDate != dateFrom || source.MaxDate != dateTo || source.Dates != expectedDays {
|
||||||
|
return fmt.Errorf("unexpected input range: %s..%s (%d dates)", source.MinDate, source.MaxDate, source.Dates)
|
||||||
|
}
|
||||||
|
mappings, err := loadMappings(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mappingPath != "" {
|
||||||
|
if err := mergeMappingCSV(mappingPath, mappings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolution := resolveInputPlates(inputPlates, mappings)
|
||||||
|
if err := createStage(ctx, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stage, err := loadStage(ctx, conn, inputPath, resolution)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stage.MappedPlates = len(resolution.Mapped)
|
||||||
|
stage.UnmappedPlates = len(resolution.Unmapped)
|
||||||
|
stage.AmbiguousPlates = len(resolution.Ambiguous)
|
||||||
|
stage.VINCollisionPlates = len(resolution.Collisions)
|
||||||
|
if stage.Rows != int64(stage.MappedPlates*expectedDays) {
|
||||||
|
return fmt.Errorf("stage row count mismatch: got %d, expected %d", stage.Rows, stage.MappedPlates*expectedDays)
|
||||||
|
}
|
||||||
|
existing, err := inspectExisting(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := result{
|
||||||
|
Mode: "dry_run",
|
||||||
|
StartedAt: started.Format(time.RFC3339),
|
||||||
|
Source: source,
|
||||||
|
Stage: stage,
|
||||||
|
Existing: existing,
|
||||||
|
UnmappedPlates: resolution.Unmapped,
|
||||||
|
AmbiguousPlates: resolution.Ambiguous,
|
||||||
|
CollisionPlates: resolution.Collisions,
|
||||||
|
SourceID: g7SourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if apply {
|
||||||
|
out.Mode = "apply"
|
||||||
|
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.BackupFinal = filepath.Join(backupDir, "vehicle_daily_mileage_before.csv.gz")
|
||||||
|
out.BackupCandidates = filepath.Join(backupDir, "vehicle_daily_mileage_source_before.csv.gz")
|
||||||
|
if err := backupQuery(ctx, conn, out.BackupFinal, `
|
||||||
|
SELECT m.*
|
||||||
|
FROM vehicle_daily_mileage m
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = m.vin AND s.stat_date = m.stat_date
|
||||||
|
WHERE m.protocol = 'JT808'
|
||||||
|
ORDER BY m.vin, m.stat_date`); err != nil {
|
||||||
|
return fmt.Errorf("backup final rows: %w", err)
|
||||||
|
}
|
||||||
|
if err := backupQuery(ctx, conn, out.BackupCandidates, `
|
||||||
|
SELECT c.*
|
||||||
|
FROM vehicle_daily_mileage_source c
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = c.vin AND s.stat_date = c.stat_date
|
||||||
|
WHERE c.protocol = 'JT808'
|
||||||
|
ORDER BY c.vin, c.stat_date, c.source_key`); err != nil {
|
||||||
|
return fmt.Errorf("backup candidate rows: %w", err)
|
||||||
|
}
|
||||||
|
finalRows, finalTotal, candidateRows, otherSelected, err := applyImport(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.FinalMatchedRows = finalRows
|
||||||
|
out.FinalTotalKM = finalTotal
|
||||||
|
out.CandidateMatchedRows = candidateRows
|
||||||
|
out.OtherSelectedRows = otherSelected
|
||||||
|
}
|
||||||
|
out.CompletedAt = time.Now().Format(time.RFC3339)
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type plateResolution struct {
|
||||||
|
Mapped map[string]string
|
||||||
|
Unmapped []string
|
||||||
|
Ambiguous []string
|
||||||
|
Collisions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveInputPlates(plates map[string]struct{}, mappings map[string]mapping) plateResolution {
|
||||||
|
result := plateResolution{Mapped: make(map[string]string)}
|
||||||
|
vinToPlates := make(map[string][]string)
|
||||||
|
for plate := range plates {
|
||||||
|
value, ok := mappings[plate]
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
result.Unmapped = append(result.Unmapped, plate)
|
||||||
|
case value.Ambiguous:
|
||||||
|
result.Ambiguous = append(result.Ambiguous, plate)
|
||||||
|
default:
|
||||||
|
result.Mapped[plate] = value.VIN
|
||||||
|
vinToPlates[value.VIN] = append(vinToPlates[value.VIN], plate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, presentPlates := range vinToPlates {
|
||||||
|
if len(presentPlates) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, plate := range presentPlates {
|
||||||
|
delete(result.Mapped, plate)
|
||||||
|
result.Collisions = append(result.Collisions, plate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(result.Unmapped)
|
||||||
|
sort.Strings(result.Ambiguous)
|
||||||
|
sort.Strings(result.Collisions)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanInput(inputPath string) (sourceStats, map[string]struct{}, error) {
|
||||||
|
reader, closeReader, err := openCSV(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return sourceStats{}, nil, err
|
||||||
|
}
|
||||||
|
defer closeReader()
|
||||||
|
csvReader := csv.NewReader(reader)
|
||||||
|
header, err := csvReader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return sourceStats{}, nil, err
|
||||||
|
}
|
||||||
|
if len(header) < 3 {
|
||||||
|
return sourceStats{}, nil, errors.New("input CSV needs plate,date,daily_mileage_km")
|
||||||
|
}
|
||||||
|
header[0] = strings.TrimPrefix(header[0], "\uFEFF")
|
||||||
|
if header[0] != "plate" || header[1] != "date" || header[2] != "daily_mileage_km" {
|
||||||
|
return sourceStats{}, nil, fmt.Errorf("unexpected CSV header: %v", header)
|
||||||
|
}
|
||||||
|
plates := make(map[string]struct{})
|
||||||
|
dates := make(map[string]struct{})
|
||||||
|
stats := sourceStats{}
|
||||||
|
for {
|
||||||
|
record, readErr := csvReader.Read()
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return sourceStats{}, nil, readErr
|
||||||
|
}
|
||||||
|
if len(record) < 3 {
|
||||||
|
return sourceStats{}, nil, fmt.Errorf("short CSV row at %d", stats.Rows+2)
|
||||||
|
}
|
||||||
|
plate := strings.TrimSpace(record[0])
|
||||||
|
date := strings.TrimSpace(record[1])
|
||||||
|
mileage, parseErr := strconv.ParseFloat(strings.TrimSpace(record[2]), 64)
|
||||||
|
if parseErr != nil || mileage < 0 || mileage > 2500 {
|
||||||
|
return sourceStats{}, nil, fmt.Errorf("invalid mileage at row %d: %q", stats.Rows+2, record[2])
|
||||||
|
}
|
||||||
|
if _, parseErr = time.Parse("2006-01-02", date); parseErr != nil {
|
||||||
|
return sourceStats{}, nil, fmt.Errorf("invalid date at row %d: %q", stats.Rows+2, date)
|
||||||
|
}
|
||||||
|
if plate == "" {
|
||||||
|
return sourceStats{}, nil, fmt.Errorf("empty plate at row %d", stats.Rows+2)
|
||||||
|
}
|
||||||
|
plates[plate] = struct{}{}
|
||||||
|
dates[date] = struct{}{}
|
||||||
|
stats.Rows++
|
||||||
|
stats.TotalKM += mileage
|
||||||
|
if mileage > 0 {
|
||||||
|
stats.Positive++
|
||||||
|
} else {
|
||||||
|
stats.Zero++
|
||||||
|
}
|
||||||
|
if stats.MinDate == "" || date < stats.MinDate {
|
||||||
|
stats.MinDate = date
|
||||||
|
}
|
||||||
|
if date > stats.MaxDate {
|
||||||
|
stats.MaxDate = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.PlateCount = len(plates)
|
||||||
|
stats.Vehicles = len(plates)
|
||||||
|
stats.Dates = len(dates)
|
||||||
|
stats.TotalKM = round(stats.TotalKM)
|
||||||
|
return stats, plates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMappings(ctx context.Context, conn *sql.Conn) (map[string]mapping, error) {
|
||||||
|
rows, err := conn.QueryContext(ctx, `
|
||||||
|
SELECT plate, vin
|
||||||
|
FROM (
|
||||||
|
SELECT TRIM(plate) AS plate, TRIM(vin) AS vin
|
||||||
|
FROM vehicle
|
||||||
|
WHERE plate IS NOT NULL AND TRIM(plate) <> ''
|
||||||
|
AND vin IS NOT NULL AND TRIM(vin) <> ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT TRIM(plate) AS plate, TRIM(vin) AS vin
|
||||||
|
FROM vehicle_identifier
|
||||||
|
WHERE enabled = 1 AND plate IS NOT NULL AND TRIM(plate) <> ''
|
||||||
|
AND vin IS NOT NULL AND TRIM(vin) <> ''
|
||||||
|
) mappings`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make(map[string]mapping)
|
||||||
|
for rows.Next() {
|
||||||
|
var plate, vin string
|
||||||
|
if err := rows.Scan(&plate, &vin); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, ok := result[plate]
|
||||||
|
if !ok {
|
||||||
|
result[plate] = mapping{VIN: vin}
|
||||||
|
} else if current.VIN != vin {
|
||||||
|
current.Ambiguous = true
|
||||||
|
result[plate] = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMappingCSV(inputPath string, mappings map[string]mapping) error {
|
||||||
|
file, err := os.Open(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(header) < 2 {
|
||||||
|
return errors.New("mapping CSV must contain vin,plate")
|
||||||
|
}
|
||||||
|
header[0] = strings.TrimPrefix(header[0], "\uFEFF")
|
||||||
|
if header[0] != "vin" || header[1] != "plate" {
|
||||||
|
return fmt.Errorf("unexpected mapping CSV header: %v", header[:2])
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
record, readErr := reader.Read()
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if len(record) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vin := strings.TrimSpace(record[0])
|
||||||
|
plate := strings.TrimSpace(record[1])
|
||||||
|
if vin == "" || plate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current, ok := mappings[plate]
|
||||||
|
if !ok {
|
||||||
|
mappings[plate] = mapping{VIN: vin}
|
||||||
|
} else if current.VIN != vin {
|
||||||
|
current.Ambiguous = true
|
||||||
|
mappings[plate] = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createStage(ctx context.Context, conn *sql.Conn) error {
|
||||||
|
_, err := conn.ExecContext(ctx, `
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_g7_mileage_import`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = conn.ExecContext(ctx, `
|
||||||
|
CREATE TEMPORARY TABLE tmp_g7_mileage_import (
|
||||||
|
vin varchar(32) NOT NULL,
|
||||||
|
plate varchar(32) NOT NULL,
|
||||||
|
stat_date date NOT NULL,
|
||||||
|
daily_mileage_km decimal(18,3) NOT NULL,
|
||||||
|
PRIMARY KEY (vin, stat_date),
|
||||||
|
KEY idx_tmp_plate (plate),
|
||||||
|
KEY idx_tmp_date (stat_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStage(ctx context.Context, conn *sql.Conn, inputPath string, resolution plateResolution) (stageStats, error) {
|
||||||
|
reader, closeReader, err := openCSV(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return stageStats{}, err
|
||||||
|
}
|
||||||
|
defer closeReader()
|
||||||
|
csvReader := csv.NewReader(reader)
|
||||||
|
if _, err := csvReader.Read(); err != nil {
|
||||||
|
return stageStats{}, err
|
||||||
|
}
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return stageStats{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
type stageRow struct {
|
||||||
|
VIN, Plate, Date string
|
||||||
|
Mileage float64
|
||||||
|
}
|
||||||
|
batch := make([]stageRow, 0, 1000)
|
||||||
|
flush := func() error {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var query strings.Builder
|
||||||
|
query.WriteString("INSERT INTO tmp_g7_mileage_import (vin, plate, stat_date, daily_mileage_km) VALUES ")
|
||||||
|
args := make([]any, 0, len(batch)*4)
|
||||||
|
for index, row := range batch {
|
||||||
|
if index > 0 {
|
||||||
|
query.WriteByte(',')
|
||||||
|
}
|
||||||
|
query.WriteString("(?,?,?,?)")
|
||||||
|
args = append(args, row.VIN, row.Plate, row.Date, row.Mileage)
|
||||||
|
}
|
||||||
|
if _, execErr := tx.ExecContext(ctx, query.String(), args...); execErr != nil {
|
||||||
|
return execErr
|
||||||
|
}
|
||||||
|
batch = batch[:0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
record, readErr := csvReader.Read()
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return stageStats{}, readErr
|
||||||
|
}
|
||||||
|
plate := strings.TrimSpace(record[0])
|
||||||
|
vin, ok := resolution.Mapped[plate]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mileage, _ := strconv.ParseFloat(strings.TrimSpace(record[2]), 64)
|
||||||
|
batch = append(batch, stageRow{
|
||||||
|
VIN: vin, Plate: plate, Date: strings.TrimSpace(record[1]), Mileage: mileage,
|
||||||
|
})
|
||||||
|
if len(batch) == cap(batch) {
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return stageStats{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return stageStats{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return stageStats{}, err
|
||||||
|
}
|
||||||
|
var stats stageStats
|
||||||
|
var nullableSum sql.NullFloat64
|
||||||
|
err = conn.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*), COUNT(DISTINCT vin), COUNT(DISTINCT stat_date),
|
||||||
|
SUM(daily_mileage_km > 0), SUM(daily_mileage_km = 0),
|
||||||
|
SUM(daily_mileage_km)
|
||||||
|
FROM tmp_g7_mileage_import`).Scan(
|
||||||
|
&stats.Rows, &stats.Vehicles, &stats.Dates, &stats.Positive, &stats.Zero, &nullableSum,
|
||||||
|
)
|
||||||
|
if nullableSum.Valid {
|
||||||
|
stats.TotalKM = round(nullableSum.Float64)
|
||||||
|
}
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectExisting(ctx context.Context, conn *sql.Conn) (existingStats, error) {
|
||||||
|
var stats existingStats
|
||||||
|
var nullableSum sql.NullFloat64
|
||||||
|
err := conn.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
SUM(m.vin IS NOT NULL),
|
||||||
|
SUM(m.vin IS NULL),
|
||||||
|
SUM(m.vin IS NOT NULL AND ABS(m.daily_mileage_km - s.daily_mileage_km) > 0.0005),
|
||||||
|
SUM(m.vin IS NOT NULL AND ABS(m.daily_mileage_km - s.daily_mileage_km) <= 0.0005),
|
||||||
|
SUM(m.vin IS NOT NULL AND COALESCE(m.source_id, 0) <> ?),
|
||||||
|
SUM(CASE WHEN m.vin IS NOT NULL THEN m.daily_mileage_km ELSE 0 END)
|
||||||
|
FROM tmp_g7_mileage_import s
|
||||||
|
LEFT JOIN vehicle_daily_mileage m
|
||||||
|
ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = 'JT808'`, g7SourceID).Scan(
|
||||||
|
&stats.ExistingFinalRows,
|
||||||
|
&stats.NewFinalRows,
|
||||||
|
&stats.ChangedExistingFinalRows,
|
||||||
|
&stats.UnchangedExistingFinalRows,
|
||||||
|
&stats.OtherSourceFinalRows,
|
||||||
|
&nullableSum,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
if nullableSum.Valid {
|
||||||
|
stats.ExistingFinalTotalKM = round(nullableSum.Float64)
|
||||||
|
}
|
||||||
|
err = conn.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM vehicle_daily_mileage_source c
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = c.vin AND s.stat_date = c.stat_date
|
||||||
|
WHERE c.protocol = 'JT808' AND c.is_selected = 1
|
||||||
|
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')`).Scan(
|
||||||
|
&stats.OtherSelectedCandidateRows,
|
||||||
|
)
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyImport(ctx context.Context, conn *sql.Conn) (int64, float64, int64, int64, error) {
|
||||||
|
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE vehicle_daily_mileage_source c
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = c.vin AND s.stat_date = c.stat_date
|
||||||
|
SET c.is_selected = 0
|
||||||
|
WHERE c.protocol = 'JT808'
|
||||||
|
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
|
||||||
|
AND c.is_selected <> 0`); err != nil {
|
||||||
|
return 0, 0, 0, 0, fmt.Errorf("clear other selected candidates: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO vehicle_daily_mileage_source (
|
||||||
|
vin, stat_date, protocol, source_key, source_ip, source_endpoint,
|
||||||
|
platform_name, first_total_mileage_km, latest_total_mileage_km,
|
||||||
|
daily_mileage_km, sample_count, first_event_time, latest_event_time,
|
||||||
|
quality_status, quality_reason, is_selected
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.vin, s.stat_date, 'JT808',
|
||||||
|
CONCAT('JT808:', s.vin, '@PLATFORM:g7s_excel'),
|
||||||
|
'manual-g7s-excel', 'manual-g7s-excel',
|
||||||
|
'G7s Excel Override', NULL, NULL,
|
||||||
|
s.daily_mileage_km, 1,
|
||||||
|
TIMESTAMP(s.stat_date, '00:00:00'),
|
||||||
|
TIMESTAMP(s.stat_date, '23:59:59'),
|
||||||
|
'OK', ?, 1
|
||||||
|
FROM tmp_g7_mileage_import s
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
source_ip = VALUES(source_ip),
|
||||||
|
source_endpoint = VALUES(source_endpoint),
|
||||||
|
platform_name = VALUES(platform_name),
|
||||||
|
first_total_mileage_km = NULL,
|
||||||
|
latest_total_mileage_km = NULL,
|
||||||
|
daily_mileage_km = VALUES(daily_mileage_km),
|
||||||
|
sample_count = VALUES(sample_count),
|
||||||
|
first_event_time = VALUES(first_event_time),
|
||||||
|
latest_event_time = VALUES(latest_event_time),
|
||||||
|
quality_status = VALUES(quality_status),
|
||||||
|
quality_reason = VALUES(quality_reason),
|
||||||
|
is_selected = 1`, g7QualityReason); err != nil {
|
||||||
|
return 0, 0, 0, 0, fmt.Errorf("upsert G7 candidates: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO vehicle_daily_mileage (
|
||||||
|
vin, stat_date, protocol, source_id, daily_mileage_km,
|
||||||
|
latest_total_mileage_km, updated_at
|
||||||
|
)
|
||||||
|
SELECT vin, stat_date, 'JT808', ?, daily_mileage_km, NULL, NOW()
|
||||||
|
FROM tmp_g7_mileage_import
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
source_id = VALUES(source_id),
|
||||||
|
daily_mileage_km = VALUES(daily_mileage_km),
|
||||||
|
latest_total_mileage_km = NULL,
|
||||||
|
updated_at = NOW()`, g7SourceID); err != nil {
|
||||||
|
return 0, 0, 0, 0, fmt.Errorf("upsert final mileage: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE vehicle_data_source
|
||||||
|
SET latest_seen_at = GREATEST(COALESCE(latest_seen_at, '2026-06-30 23:59:59'), '2026-06-30 23:59:59'),
|
||||||
|
first_seen_at = LEAST(COALESCE(first_seen_at, '2026-01-01 00:00:00'), '2026-01-01 00:00:00'),
|
||||||
|
latest_source_endpoint = 'manual-g7s-excel',
|
||||||
|
platform_name = 'G7s Excel Override',
|
||||||
|
source_code = 'g7s_excel',
|
||||||
|
source_kind = 'PLATFORM',
|
||||||
|
trust_priority = 0,
|
||||||
|
enabled = 1,
|
||||||
|
remark = 'Manual GPS override imported from G7s quarterly daily mileage reports for 2026-01-01 through 2026-06-30 on 2026-07-28'
|
||||||
|
WHERE id = ? AND protocol = 'JT808' AND source_ip = 'manual-g7s-excel'`, g7SourceID); err != nil {
|
||||||
|
return 0, 0, 0, 0, fmt.Errorf("update G7 source metadata: %w", err)
|
||||||
|
}
|
||||||
|
var finalRows, candidateRows, otherSelected int64
|
||||||
|
var finalSum sql.NullFloat64
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*), SUM(m.daily_mileage_km)
|
||||||
|
FROM vehicle_daily_mileage m
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = m.vin AND s.stat_date = m.stat_date
|
||||||
|
WHERE m.protocol = 'JT808' AND m.source_id = ?
|
||||||
|
AND ABS(m.daily_mileage_km - s.daily_mileage_km) <= 0.0005`, g7SourceID).Scan(
|
||||||
|
&finalRows, &finalSum,
|
||||||
|
); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM vehicle_daily_mileage_source c
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = c.vin AND s.stat_date = c.stat_date
|
||||||
|
WHERE c.protocol = 'JT808'
|
||||||
|
AND c.source_key = CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
|
||||||
|
AND c.is_selected = 1
|
||||||
|
AND ABS(c.daily_mileage_km - s.daily_mileage_km) <= 0.0005`).Scan(&candidateRows); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM vehicle_daily_mileage_source c
|
||||||
|
JOIN tmp_g7_mileage_import s
|
||||||
|
ON s.vin = c.vin AND s.stat_date = c.stat_date
|
||||||
|
WHERE c.protocol = 'JT808'
|
||||||
|
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
|
||||||
|
AND c.is_selected = 1`).Scan(&otherSelected); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
var stageRows int64
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tmp_g7_mileage_import`).Scan(&stageRows); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
if finalRows != stageRows || candidateRows != stageRows || otherSelected != 0 {
|
||||||
|
return 0, 0, 0, 0, fmt.Errorf(
|
||||||
|
"verification failed before commit: stage=%d final=%d candidates=%d other_selected=%d",
|
||||||
|
stageRows, finalRows, candidateRows, otherSelected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
return finalRows, round(finalSum.Float64), candidateRows, otherSelected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupQuery(ctx context.Context, conn *sql.Conn, outputPath, query string) error {
|
||||||
|
rows, err := conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
gzipWriter := gzip.NewWriter(file)
|
||||||
|
defer gzipWriter.Close()
|
||||||
|
csvWriter := csv.NewWriter(gzipWriter)
|
||||||
|
defer csvWriter.Flush()
|
||||||
|
if err := csvWriter.Write(columns); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw := make([]sql.RawBytes, len(columns))
|
||||||
|
pointers := make([]any, len(columns))
|
||||||
|
for index := range raw {
|
||||||
|
pointers[index] = &raw[index]
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
if err := rows.Scan(pointers...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record := make([]string, len(columns))
|
||||||
|
for index, value := range raw {
|
||||||
|
if value == nil {
|
||||||
|
record[index] = "\\N"
|
||||||
|
} else {
|
||||||
|
record[index] = string(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := csvWriter.Write(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
csvWriter.Flush()
|
||||||
|
if err := csvWriter.Error(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := gzipWriter.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCSV(inputPath string) (io.Reader, func(), error) {
|
||||||
|
file, err := os.Open(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
closeReader := func() { _ = file.Close() }
|
||||||
|
if !strings.HasSuffix(strings.ToLower(inputPath), ".gz") {
|
||||||
|
return file, closeReader, nil
|
||||||
|
}
|
||||||
|
gzipReader, err := gzip.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return gzipReader, func() {
|
||||||
|
_ = gzipReader.Close()
|
||||||
|
_ = file.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedDSN(raw string) (string, error) {
|
||||||
|
config, err := mysql.ParseDSN(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
location, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
config.ParseTime = true
|
||||||
|
config.Loc = location
|
||||||
|
config.Timeout = 10 * time.Second
|
||||||
|
config.ReadTimeout = 30 * time.Minute
|
||||||
|
config.WriteTimeout = 30 * time.Minute
|
||||||
|
config.Params = cloneParams(config.Params)
|
||||||
|
config.Params["charset"] = "utf8mb4"
|
||||||
|
return config.FormatDSN(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneParams(input map[string]string) map[string]string {
|
||||||
|
output := make(map[string]string, len(input)+1)
|
||||||
|
for key, value := range input {
|
||||||
|
output[key] = value
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(value float64) float64 {
|
||||||
|
return float64(int64(value*100+0.5)) / 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||||
|
if dsn == "" {
|
||||||
|
panic("MYSQL_DSN is required")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
for _, table := range []string{
|
||||||
|
"vehicle_data_source",
|
||||||
|
"vehicle_daily_mileage_source",
|
||||||
|
"vehicle_daily_mileage",
|
||||||
|
"vehicle",
|
||||||
|
"vehicle_identifier",
|
||||||
|
} {
|
||||||
|
var name, ddl string
|
||||||
|
if err := db.QueryRow("SHOW CREATE TABLE "+table).Scan(&name, &ddl); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", name, ddl)
|
||||||
|
}
|
||||||
|
rows, err := db.Query("SELECT * FROM vehicle_data_source WHERE id = 661200")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if rows.Next() {
|
||||||
|
values := make([]any, len(columns))
|
||||||
|
pointers := make([]any, len(columns))
|
||||||
|
for i := range values {
|
||||||
|
pointers[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(pointers...); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
record := make(map[string]any, len(columns))
|
||||||
|
for i, column := range columns {
|
||||||
|
switch value := values[i].(type) {
|
||||||
|
case []byte:
|
||||||
|
record[column] = string(value)
|
||||||
|
default:
|
||||||
|
record[column] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(record)
|
||||||
|
fmt.Printf("=== source_661200 ===\n%s\n", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const inputPath =
|
||||||
|
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/g7-mileage-history-20260724/merged/G7车辆每日里程及里程统计_20220101-20260720.xlsx";
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(inputPath));
|
||||||
|
const sheets = await workbook.inspect({
|
||||||
|
kind: "sheet",
|
||||||
|
include: "id,name",
|
||||||
|
maxChars: 4000,
|
||||||
|
});
|
||||||
|
const summary = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
sheetId: "汇总",
|
||||||
|
range: "A1:H25",
|
||||||
|
include: "values,formulas",
|
||||||
|
maxChars: 12000,
|
||||||
|
tableMaxRows: 25,
|
||||||
|
tableMaxCols: 8,
|
||||||
|
});
|
||||||
|
const daily = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
sheetId: "每日里程非零",
|
||||||
|
range: "A1:D12",
|
||||||
|
include: "values,formulas",
|
||||||
|
maxChars: 6000,
|
||||||
|
tableMaxRows: 12,
|
||||||
|
tableMaxCols: 4,
|
||||||
|
});
|
||||||
|
const errors = await workbook.inspect({
|
||||||
|
kind: "match",
|
||||||
|
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
|
||||||
|
options: { useRegex: true, maxResults: 300 },
|
||||||
|
summary: "post-export formula error scan",
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
inputPath,
|
||||||
|
sheets: sheets.ndjson,
|
||||||
|
summary: summary.ndjson,
|
||||||
|
daily: daily.ndjson,
|
||||||
|
errors: errors.ndjson,
|
||||||
|
}, null, 2));
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const taskDir = process.env.GB32960_TASK_DIR || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gb32960-export-20260817";
|
||||||
|
const outputDir = process.env.GB32960_OUTPUT_DIR || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/gb32960-export-20260817-agp4377";
|
||||||
|
const data = JSON.parse(await fs.readFile(path.join(taskDir, "raw-frames.json"), "utf8"));
|
||||||
|
const exportDate = data.query.dateFrom.slice(0, 10);
|
||||||
|
const outputPath = path.join(outputDir, `${data.query.plate}_${exportDate}_GB32960原始数据_中文字段.xlsx`);
|
||||||
|
|
||||||
|
const categoryNames = {
|
||||||
|
alarm: "报警数据",
|
||||||
|
drive_motor: "驱动电机数据",
|
||||||
|
engine: "发动机数据",
|
||||||
|
extreme: "极值数据",
|
||||||
|
fuel_cell: "燃料电池数据",
|
||||||
|
gd_fc_air_conditioner: "广东燃料电池空调数据",
|
||||||
|
gd_fc_auxiliary: "广东燃料电池辅助系统数据",
|
||||||
|
gd_fc_dcdc: "广东燃料电池 DC/DC 数据",
|
||||||
|
gd_fc_demo_extension: "广东燃料电池演示扩展数据",
|
||||||
|
gd_fc_stack: "广东燃料电池电堆数据",
|
||||||
|
gd_fc_vehicle_info: "广东燃料电池车辆信息数据",
|
||||||
|
gd_fc_vendor_tlv: "广东燃料电池厂家 TLV 扩展",
|
||||||
|
header: "报文头",
|
||||||
|
platform: "平台信息",
|
||||||
|
position: "车辆位置数据",
|
||||||
|
temperature: "可充电储能装置温度数据",
|
||||||
|
vehicle: "整车数据",
|
||||||
|
voltage: "可充电储能装置电压数据",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldNames = {
|
||||||
|
battery_faults: "可充电储能装置故障码",
|
||||||
|
engine_faults: "发动机故障码",
|
||||||
|
general_alarm_flag: "通用报警标志",
|
||||||
|
max_alarm_level: "最高报警等级",
|
||||||
|
motor_faults: "驱动电机故障码",
|
||||||
|
other_faults: "其他故障码",
|
||||||
|
type: "数据单元类型",
|
||||||
|
count: "数量",
|
||||||
|
controller_current_a: "控制器直流母线电流",
|
||||||
|
controller_temperature_c: "控制器温度",
|
||||||
|
controller_voltage_v: "控制器输入电压",
|
||||||
|
motor_temperature_c: "电机温度",
|
||||||
|
serial_no: "驱动电机序号",
|
||||||
|
speed_rpm: "转速",
|
||||||
|
state: "状态",
|
||||||
|
torque_nm: "转矩",
|
||||||
|
engine_status: "发动机状态",
|
||||||
|
fuel_rate: "燃料消耗率",
|
||||||
|
max_temp_c: "最高温度",
|
||||||
|
max_temp_probe_no: "最高温度探针序号",
|
||||||
|
max_temp_subsystem_no: "最高温度子系统号",
|
||||||
|
max_voltage_cell_no: "最高电压单体序号",
|
||||||
|
max_voltage_subsystem_no: "最高电压子系统号",
|
||||||
|
max_voltage_v: "最高电压",
|
||||||
|
min_temp_c: "最低温度",
|
||||||
|
min_temp_probe_no: "最低温度探针序号",
|
||||||
|
min_temp_subsystem_no: "最低温度子系统号",
|
||||||
|
min_voltage_cell_no: "最低电压单体序号",
|
||||||
|
min_voltage_subsystem_no: "最低电压子系统号",
|
||||||
|
min_voltage_v: "最低电压",
|
||||||
|
dc_dc_status: "高压 DC/DC 状态",
|
||||||
|
fuel_cell_current_a: "燃料电池电流",
|
||||||
|
fuel_cell_voltage_v: "燃料电池电压",
|
||||||
|
hydrogen_consumption_kg_per_100km: "氢耗",
|
||||||
|
max_hydrogen_concentration_fraction: "最高氢浓度比例",
|
||||||
|
max_hydrogen_concentration_percent: "最高氢浓度",
|
||||||
|
max_hydrogen_concentration_ppm: "最高氢浓度原始值",
|
||||||
|
max_hydrogen_concentration_probe_id: "最高氢浓度探针编号",
|
||||||
|
max_hydrogen_pressure_mpa: "最高氢压力",
|
||||||
|
max_hydrogen_pressure_probe_id: "最高氢压探针编号",
|
||||||
|
max_hydrogen_temperature_c: "氢系统最高温度",
|
||||||
|
max_hydrogen_temperature_probe_id: "最高氢温探针编号",
|
||||||
|
temperature_probe_count: "温度探针数量",
|
||||||
|
temperature_probe_values_c: "温度探针值列表",
|
||||||
|
status: "状态",
|
||||||
|
subsystem_count: "子系统数量",
|
||||||
|
air_compressor_motor_voltage_v: "空气压缩机电机电压",
|
||||||
|
air_compressor_power_kw: "空气压缩机功率",
|
||||||
|
low_voltage_battery_voltage_v: "低压蓄电池电压",
|
||||||
|
water_pump_voltage_v: "水泵电压",
|
||||||
|
controller_temp_c: "控制器温度",
|
||||||
|
input_current_a: "输入电流",
|
||||||
|
input_voltage_v: "输入电压",
|
||||||
|
output_current_a: "输出电流",
|
||||||
|
output_voltage_v: "输出电压",
|
||||||
|
air_compressor_current_a: "空气压缩机电流",
|
||||||
|
air_compressor_voltage_v: "空气压缩机电压",
|
||||||
|
declared_length: "声明长度",
|
||||||
|
hydrogen_pump_current_a: "氢气循环泵电流",
|
||||||
|
hydrogen_pump_voltage_v: "氢气循环泵电压",
|
||||||
|
stack_temp_c: "电堆温度",
|
||||||
|
air_inlet_pressure_kpa: "空气入口压力",
|
||||||
|
avg_cell_voltage_v: "单体平均电压",
|
||||||
|
cell_count: "单体总数",
|
||||||
|
engine_work_state: "发动机工作状态",
|
||||||
|
frame_cell_voltages_v: "本帧单体电压列表",
|
||||||
|
hydrogen_inlet_pressure_kpa: "氢气入口压力",
|
||||||
|
max_cell_voltage_id: "最高电压单体编号",
|
||||||
|
max_cell_voltage_v: "单体最高电压",
|
||||||
|
min_cell_voltage_id: "最低电压单体编号",
|
||||||
|
min_cell_voltage_v: "单体最低电压",
|
||||||
|
stack_count: "电堆数量",
|
||||||
|
stack_water_outlet_temp_c: "电堆出水温度",
|
||||||
|
collision_alarm: "碰撞报警",
|
||||||
|
hydrogen_mass_kg: "车载氢量",
|
||||||
|
payload_hex: "原始载荷(十六进制)",
|
||||||
|
actual_body_length: "实际报文体长度",
|
||||||
|
body_length: "报文体长度",
|
||||||
|
command: "命令标识",
|
||||||
|
encrypt: "加密方式",
|
||||||
|
response_flag: "应答标志",
|
||||||
|
version: "协议版本",
|
||||||
|
vin: "车辆识别代号",
|
||||||
|
platform_name: "平台名称",
|
||||||
|
latitude: "纬度",
|
||||||
|
longitude: "经度",
|
||||||
|
position_status: "定位状态标志",
|
||||||
|
accelerator_pct: "加速踏板行程",
|
||||||
|
brake_pct: "制动踏板状态",
|
||||||
|
charge_status: "充电状态",
|
||||||
|
gear: "挡位原始值",
|
||||||
|
insulation_kohm: "绝缘电阻",
|
||||||
|
running_mode: "运行模式",
|
||||||
|
soc_percent: "SOC",
|
||||||
|
speed_kmh: "车速",
|
||||||
|
total_current_a: "总电流",
|
||||||
|
total_mileage_km: "累计里程",
|
||||||
|
total_voltage_v: "总电压",
|
||||||
|
vehicle_status: "车辆状态",
|
||||||
|
max_cell_v: "单体最高电压",
|
||||||
|
min_cell_v: "单体最低电压",
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitBySuffix = {
|
||||||
|
controller_current_a: "A", controller_voltage_v: "V", controller_temperature_c: "℃",
|
||||||
|
motor_temperature_c: "℃", speed_rpm: "rpm", torque_nm: "N·m", fuel_rate: "L/100km",
|
||||||
|
max_temp_c: "℃", min_temp_c: "℃", max_voltage_v: "V", min_voltage_v: "V",
|
||||||
|
fuel_cell_current_a: "A", fuel_cell_voltage_v: "V", hydrogen_consumption_kg_per_100km: "kg/100km",
|
||||||
|
max_hydrogen_concentration_fraction: "比例", max_hydrogen_concentration_percent: "%", max_hydrogen_concentration_ppm: "ppm",
|
||||||
|
max_hydrogen_pressure_mpa: "MPa", max_hydrogen_temperature_c: "℃", temperature_probe_values_c: "℃",
|
||||||
|
air_compressor_motor_voltage_v: "V", air_compressor_power_kw: "kW", low_voltage_battery_voltage_v: "V", water_pump_voltage_v: "V",
|
||||||
|
controller_temp_c: "℃", input_current_a: "A", input_voltage_v: "V", output_current_a: "A", output_voltage_v: "V",
|
||||||
|
air_compressor_current_a: "A", air_compressor_voltage_v: "V", hydrogen_pump_current_a: "A", hydrogen_pump_voltage_v: "V", stack_temp_c: "℃",
|
||||||
|
air_inlet_pressure_kpa: "kPa", avg_cell_voltage_v: "V", frame_cell_voltages_v: "V", hydrogen_inlet_pressure_kpa: "kPa",
|
||||||
|
max_cell_voltage_v: "V", min_cell_voltage_v: "V", stack_water_outlet_temp_c: "℃", hydrogen_mass_kg: "kg",
|
||||||
|
latitude: "°", longitude: "°", accelerator_pct: "%", brake_pct: "%", insulation_kohm: "kΩ", soc_percent: "%",
|
||||||
|
speed_kmh: "km/h", total_current_a: "A", total_mileage_km: "km", total_voltage_v: "V", max_cell_v: "V", min_cell_v: "V",
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueMappings = {
|
||||||
|
"gb32960.vehicle.vehicle_status": { "1": "启动", "2": "熄火", "3": "其他", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.vehicle.charge_status": { "1": "停车充电", "2": "行驶充电", "3": "未充电", "4": "充电完成", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.vehicle.running_mode": { "1": "纯电", "2": "混合动力", "3": "燃料电池", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.vehicle.dc_dc_status": { "1": "工作", "2": "断开", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.fuel_cell.dc_dc_status": { "1": "工作", "2": "断开", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.drive_motor.motors.motor_1.state": { "1": "耗电", "2": "发电", "3": "关闭", "4": "准备", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.alarm.max_alarm_level": { "0": "无故障", "1": "一级故障", "2": "二级故障", "3": "三级故障" },
|
||||||
|
"gb32960.gd_fc_air_conditioner.status": { "0": "关闭", "1": "启动", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.gd_fc_vehicle_info.collision_alarm": { "0": "无碰撞报警", "1": "有碰撞报警", "254": "异常", "255": "无效" },
|
||||||
|
"gb32960.position.position_status": {
|
||||||
|
"0": "有效定位 · 北纬 · 东经", "1": "无效定位 · 北纬 · 东经", "2": "有效定位 · 南纬 · 东经", "3": "无效定位 · 南纬 · 东经",
|
||||||
|
"4": "有效定位 · 北纬 · 西经", "5": "无效定位 · 北纬 · 西经", "6": "有效定位 · 南纬 · 西经", "7": "无效定位 · 南纬 · 西经",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldParts(key) {
|
||||||
|
const parts = key.split(".");
|
||||||
|
const category = parts[1] ?? "";
|
||||||
|
const suffix = parts.at(-1) ?? key;
|
||||||
|
const motorMatch = key.match(/\.motors\.motor_(\d+)\./);
|
||||||
|
const subsystemMatch = key.match(/\.subsystems\.subsystem_(\d+)\./);
|
||||||
|
const scope = motorMatch ? `电机${motorMatch[1]}-` : subsystemMatch ? `子系统${subsystemMatch[1]}-` : "";
|
||||||
|
const categoryName = categoryNames[category] ?? "GB/T 32960 扩展数据";
|
||||||
|
const label = fieldNames[suffix];
|
||||||
|
if (!label) throw new Error(`missing Chinese field mapping for ${key}`);
|
||||||
|
const unit = unitBySuffix[suffix] ?? "";
|
||||||
|
return { category, categoryName, suffix, label: `${categoryName}-${scope}${label}${unit ? `(${unit})` : ""}`, unit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(key, value) {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const raw = String(value);
|
||||||
|
const mapped = valueMappings[key]?.[raw];
|
||||||
|
if (mapped) return `${mapped}(${raw})`;
|
||||||
|
if (/^0x[0-9a-f]+$/i.test(raw)) return `十六进制 ${raw}`;
|
||||||
|
if (/^-?\d+(?:\.\d+)?$/.test(raw) && !key.endsWith(".vin")) return Number(raw);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mappingText(key) {
|
||||||
|
const mapping = valueMappings[key];
|
||||||
|
return mapping ? Object.entries(mapping).map(([value, label]) => `${value}=${label}`).join(";") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnName(index) {
|
||||||
|
let value = index + 1;
|
||||||
|
let out = "";
|
||||||
|
while (value > 0) {
|
||||||
|
const remainder = (value - 1) % 26;
|
||||||
|
out = String.fromCharCode(65 + remainder) + out;
|
||||||
|
value = Math.floor((value - 1) / 26);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldKeys = data.fieldKeys;
|
||||||
|
const mappedFields = fieldKeys.map((key) => ({ key, ...fieldParts(key) }));
|
||||||
|
const metadata = [
|
||||||
|
["序号", (item, index) => index + 1],
|
||||||
|
["车牌", () => data.query.plate],
|
||||||
|
["VIN", (item) => item.vin],
|
||||||
|
["协议", (item) => item.protocol],
|
||||||
|
["设备时间(北京时间)", (item) => item.event_time],
|
||||||
|
["接收时间(北京时间)", (item) => item.received_at],
|
||||||
|
["存储时间(北京时间)", (item) => item.ts],
|
||||||
|
["消息ID", (item) => item.message_id],
|
||||||
|
["消息ID(十六进制)", (item) => item.message_id_hex ? `十六进制 ${item.message_id_hex}` : null],
|
||||||
|
["帧ID", (item) => item.frame_id],
|
||||||
|
["事件ID", (item) => item.event_id],
|
||||||
|
["解析状态", (item) => item.parse_status],
|
||||||
|
["解析错误", (item) => item.parse_error ?? null],
|
||||||
|
["来源端点", (item) => item.source_endpoint],
|
||||||
|
["原始报文字节数", (item) => item.raw_size_bytes],
|
||||||
|
["车辆标识", (item) => item.vehicle_key],
|
||||||
|
["手机号", (item) => item.phone ?? null],
|
||||||
|
["设备ID", (item) => item.device_id ?? null],
|
||||||
|
["原始文本", (item) => item.raw_text ?? null],
|
||||||
|
["原始报文(十六进制)", (item) => item.raw_hex ?? null],
|
||||||
|
];
|
||||||
|
|
||||||
|
const workbook = Workbook.create();
|
||||||
|
const summary = workbook.worksheets.add("导出说明");
|
||||||
|
const detail = workbook.worksheets.add("完整数据");
|
||||||
|
const mapping = workbook.worksheets.add("字段映射");
|
||||||
|
|
||||||
|
summary.showGridLines = false;
|
||||||
|
summary.getRange("A1:F1").merge();
|
||||||
|
summary.getRange("A1").values = [[`${data.query.plate} · ${exportDate} · GB/T 32960 原始数据导出`]];
|
||||||
|
summary.getRange("A1:F1").format = { fill: "#163A5F", font: { bold: true, color: "#FFFFFF", size: 16 }, verticalAlignment: "center" };
|
||||||
|
summary.getRange("A1:F1").format.rowHeight = 32;
|
||||||
|
summary.getRange("A3:B15").values = [
|
||||||
|
["车牌", data.query.plate],
|
||||||
|
["VIN", data.query.vin],
|
||||||
|
["协议", data.query.protocol],
|
||||||
|
["查询日期", `${exportDate}(北京时间)`],
|
||||||
|
["查询时间窗", `${data.query.dateFrom} 至 ${data.query.dateTo}`],
|
||||||
|
["接口统计总帧数", data.verification.apiTotal],
|
||||||
|
["实际导出帧数", data.verification.fetchedRows],
|
||||||
|
["唯一帧ID数", data.verification.uniqueFrameIds],
|
||||||
|
["唯一事件ID数", data.verification.uniqueEventIds],
|
||||||
|
["首条存储时间", data.verification.earliestTs],
|
||||||
|
["末条存储时间", data.verification.latestTs],
|
||||||
|
["解析字段数", data.verification.parsedFieldCount],
|
||||||
|
["消息类型统计", Object.entries(data.verification.messageCounts).map(([key, count]) => `${key}: ${count}帧`).join(";")],
|
||||||
|
];
|
||||||
|
summary.getRange("A3:A15").format = { fill: "#DCE8F2", font: { bold: true, color: "#163A5F" } };
|
||||||
|
summary.getRange("A3:B15").format.borders = { preset: "inside", style: "thin", color: "#C8D4E0" };
|
||||||
|
summary.getRange("A17:F17").merge();
|
||||||
|
summary.getRange("A17").values = [["口径说明"]];
|
||||||
|
summary.getRange("A17:F17").format = { fill: "#2B6F91", font: { bold: true, color: "#FFFFFF" } };
|
||||||
|
summary.getRange("A18:F22").merge(true);
|
||||||
|
summary.getRange("A18:F22").values = [
|
||||||
|
[`1. 数据源为生产实时 API 的 GB32960 原始帧历史;导出保留原始报文十六进制、帧/事件标识、解析状态及全部 ${data.verification.parsedFieldCount} 个已出现的解析字段。`],
|
||||||
|
["2. 完整数据工作表使用中文字段名;字段映射工作表保留原始字段键、单位及状态值映射,便于审计和程序回溯。"],
|
||||||
|
["3. 状态类字段显示为“中文含义(协议值)”;普通测量值保持数值,列表/位图/十六进制载荷保持原始文本。"],
|
||||||
|
[`4. 查询边界为北京时间 ${data.query.dateFrom} 至 ${data.query.dateTo};该车辆实际存储时间覆盖 ${data.verification.earliestTs} 至 ${data.verification.latestTs}。`],
|
||||||
|
["5. 中文术语参考:项目 GB/T 32960 字段展示基准及【字段】32960+广东燃料电池协议.json(SHA-256: 0f0847b86cc692f2130255babbee37e6cb3cf36d717b18d6698164289f66eb82)。"],
|
||||||
|
];
|
||||||
|
summary.getRange("A18:F22").format = { wrapText: true, verticalAlignment: "top", font: { color: "#334155", size: 10 } };
|
||||||
|
summary.getRange("A18:F22").format.rowHeight = 34;
|
||||||
|
summary.getRange("A:A").format.columnWidth = 22;
|
||||||
|
summary.getRange("B:F").format.columnWidth = 18;
|
||||||
|
summary.getRange("B:B").format.columnWidth = 78;
|
||||||
|
|
||||||
|
const detailHeaders = [...metadata.map(([label]) => label), ...mappedFields.map((field) => field.label)];
|
||||||
|
const detailRows = data.items.map((item, index) => [
|
||||||
|
...metadata.map(([, getter]) => getter(item, index)),
|
||||||
|
...mappedFields.map(({ key }) => displayValue(key, item.parsed_fields?.[key])),
|
||||||
|
]);
|
||||||
|
const detailMatrix = [detailHeaders, ...detailRows];
|
||||||
|
const detailEndColumn = columnName(detailHeaders.length - 1);
|
||||||
|
detail.getRangeByIndexes(0, 0, detailMatrix.length, detailHeaders.length).values = detailMatrix;
|
||||||
|
detail.showGridLines = false;
|
||||||
|
detail.freezePanes.freezeRows(1);
|
||||||
|
detail.freezePanes.freezeColumns(3);
|
||||||
|
const dataTable = detail.tables.add(`A1:${detailEndColumn}${detailMatrix.length}`, true, "GB32960DataTable");
|
||||||
|
dataTable.style = "TableStyleMedium2";
|
||||||
|
dataTable.showFilterButton = true;
|
||||||
|
dataTable.showBandedColumns = false;
|
||||||
|
detail.getRangeByIndexes(0, 0, 1, detailHeaders.length).format = {
|
||||||
|
fill: "#1F4E78", font: { bold: true, color: "#FFFFFF", size: 9 }, wrapText: true,
|
||||||
|
horizontalAlignment: "center", verticalAlignment: "center",
|
||||||
|
};
|
||||||
|
detail.getRangeByIndexes(0, 0, 1, detailHeaders.length).format.rowHeight = 48;
|
||||||
|
detail.getRangeByIndexes(1, 0, detailRows.length, detailHeaders.length).format.font = { size: 9, color: "#1F2937" };
|
||||||
|
detail.getRangeByIndexes(0, 0, detailMatrix.length, 1).format.columnWidth = 8;
|
||||||
|
detail.getRangeByIndexes(0, 1, detailMatrix.length, 1).format.columnWidth = 13;
|
||||||
|
detail.getRangeByIndexes(0, 2, detailMatrix.length, 1).format.columnWidth = 22;
|
||||||
|
for (const col of [3, 7, 8, 11, 14]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 14;
|
||||||
|
for (const col of [4, 5, 6]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 21;
|
||||||
|
for (const col of [9, 10]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 36;
|
||||||
|
for (const col of [12, 13, 15, 16, 17]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 20;
|
||||||
|
detail.getRangeByIndexes(0, 18, detailMatrix.length, 1).format.columnWidth = 28;
|
||||||
|
detail.getRangeByIndexes(0, 19, detailMatrix.length, 1).format.columnWidth = 44;
|
||||||
|
for (let col = metadata.length; col < detailHeaders.length; col += 1) {
|
||||||
|
const key = mappedFields[col - metadata.length].key;
|
||||||
|
const wide = key.includes("voltages_v") || key.endsWith("faults") || key.endsWith("payload_hex");
|
||||||
|
detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = wide ? 38 : 18;
|
||||||
|
}
|
||||||
|
detail.getRangeByIndexes(1, 0, detailRows.length, 1).format.numberFormat = "0";
|
||||||
|
detail.getRangeByIndexes(1, 7, detailRows.length, 1).format.numberFormat = "0";
|
||||||
|
detail.getRangeByIndexes(1, 14, detailRows.length, 1).format.numberFormat = "0";
|
||||||
|
|
||||||
|
mapping.showGridLines = false;
|
||||||
|
mapping.freezePanes.freezeRows(1);
|
||||||
|
const mappingHeaders = ["序号", "中文字段名", "原始字段键", "数据类别", "单位", "状态值映射", "说明"];
|
||||||
|
const mappingRows = mappedFields.map((field, index) => [
|
||||||
|
index + 1,
|
||||||
|
field.label,
|
||||||
|
field.key,
|
||||||
|
field.categoryName,
|
||||||
|
field.unit,
|
||||||
|
mappingText(field.key),
|
||||||
|
valueMappings[field.key] ? "完整数据中显示中文含义并保留括号内协议值。" : "值按协议解析结果原样导出;数值字段保持数值类型。",
|
||||||
|
]);
|
||||||
|
mapping.getRangeByIndexes(0, 0, mappingRows.length + 1, mappingHeaders.length).values = [mappingHeaders, ...mappingRows];
|
||||||
|
const mappingTable = mapping.tables.add(`A1:G${mappingRows.length + 1}`, true, "GB32960FieldMappingTable");
|
||||||
|
mappingTable.style = "TableStyleMedium2";
|
||||||
|
mapping.getRange("A1:G1").format = { fill: "#1F4E78", font: { bold: true, color: "#FFFFFF" }, wrapText: true, horizontalAlignment: "center" };
|
||||||
|
mapping.getRange("A1:G1").format.rowHeight = 30;
|
||||||
|
mapping.getRange("A:A").format.columnWidth = 8;
|
||||||
|
mapping.getRange("B:B").format.columnWidth = 42;
|
||||||
|
mapping.getRange("C:C").format.columnWidth = 58;
|
||||||
|
mapping.getRange("D:D").format.columnWidth = 30;
|
||||||
|
mapping.getRange("E:E").format.columnWidth = 12;
|
||||||
|
mapping.getRange("F:F").format.columnWidth = 68;
|
||||||
|
mapping.getRange("G:G").format.columnWidth = 48;
|
||||||
|
mapping.getRange(`B2:G${mappingRows.length + 1}`).format.wrapText = true;
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const summaryInspect = await workbook.inspect({ kind: "table", range: "导出说明!A1:F22", include: "values,formulas", tableMaxRows: 24, tableMaxCols: 8 });
|
||||||
|
const detailInspect = await workbook.inspect({ kind: "table", range: "完整数据!A1:Z8", include: "values,formulas", tableMaxRows: 8, tableMaxCols: 26 });
|
||||||
|
const mappingInspect = await workbook.inspect({ kind: "table", range: "字段映射!A1:G16", include: "values,formulas", tableMaxRows: 16, tableMaxCols: 8 });
|
||||||
|
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" });
|
||||||
|
await fs.writeFile(path.join(taskDir, "summary-inspect.ndjson"), summaryInspect.ndjson, "utf8");
|
||||||
|
await fs.writeFile(path.join(taskDir, "detail-inspect.ndjson"), detailInspect.ndjson, "utf8");
|
||||||
|
await fs.writeFile(path.join(taskDir, "mapping-inspect.ndjson"), mappingInspect.ndjson, "utf8");
|
||||||
|
await fs.writeFile(path.join(taskDir, "formula-errors.ndjson"), errors.ndjson, "utf8");
|
||||||
|
|
||||||
|
for (const [sheetName, range, filename] of [
|
||||||
|
["导出说明", "A1:F22", "preview-summary.png"],
|
||||||
|
["完整数据", "A1:T18", "preview-detail.png"],
|
||||||
|
["字段映射", "A1:G28", "preview-mapping.png"],
|
||||||
|
]) {
|
||||||
|
const preview = await workbook.render({ sheetName, range, scale: 1.25, format: "png" });
|
||||||
|
await fs.writeFile(path.join(taskDir, filename), new Uint8Array(await preview.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const xlsx = await SpreadsheetFile.exportXlsx(workbook);
|
||||||
|
await xlsx.save(outputPath);
|
||||||
|
console.log(JSON.stringify({ outputPath, sheets: ["导出说明", "完整数据", "字段映射"], rows: detailRows.length, parsedFields: mappedFields.length, columns: detailHeaders.length }, null, 2));
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const baseUrl = process.env.GB32960_BASE_URL || "http://115.29.187.205:20200";
|
||||||
|
const vin = process.env.GB32960_VIN || "LB9A32A24R0LS1720";
|
||||||
|
const plate = process.env.GB32960_PLATE || "粤AGP4377";
|
||||||
|
const exportDate = process.env.GB32960_DATE || "2026-08-17";
|
||||||
|
const dateFrom = `${exportDate} 00:00:00`;
|
||||||
|
const dateTo = `${exportDate} 23:59:59`;
|
||||||
|
const limit = 500;
|
||||||
|
const outputPath = process.env.GB32960_DATA_PATH || new URL("./raw-frames.json", import.meta.url).pathname;
|
||||||
|
|
||||||
|
async function fetchPage(offset, includeTotal = false) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
protocol: "GB32960",
|
||||||
|
vin,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
orderBy: "eventTime",
|
||||||
|
limit: String(limit),
|
||||||
|
offset: String(offset),
|
||||||
|
includeFields: "true",
|
||||||
|
includePayload: "true",
|
||||||
|
includeTotal: includeTotal ? "true" : "false",
|
||||||
|
});
|
||||||
|
const response = await fetch(`${baseUrl}/api/history/raw-frames?${params}`, {
|
||||||
|
headers: { "Accept-Encoding": "gzip" },
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await fetchPage(0, true);
|
||||||
|
const total = Number(first.total ?? 0);
|
||||||
|
const offsets = [];
|
||||||
|
for (let offset = limit; offset < total; offset += limit) offsets.push(offset);
|
||||||
|
const remaining = await Promise.all(offsets.map((offset) => fetchPage(offset)));
|
||||||
|
const items = [...(first.items ?? []), ...remaining.flatMap((page) => page.items ?? [])];
|
||||||
|
|
||||||
|
const frameIds = new Set(items.map((item) => item.frame_id));
|
||||||
|
const eventIds = new Set(items.map((item) => item.event_id));
|
||||||
|
const fieldKeys = [...new Set(items.flatMap((item) => Object.keys(item.parsed_fields ?? {})))].sort();
|
||||||
|
const messageCounts = Object.fromEntries(
|
||||||
|
[...items.reduce((map, item) => map.set(item.message_id_hex, (map.get(item.message_id_hex) ?? 0) + 1), new Map())]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length !== total) throw new Error(`row count mismatch: fetched=${items.length} total=${total}`);
|
||||||
|
if (frameIds.size !== items.length) throw new Error(`duplicate frame_id: unique=${frameIds.size} rows=${items.length}`);
|
||||||
|
if (eventIds.size !== items.length) throw new Error(`duplicate event_id: unique=${eventIds.size} rows=${items.length}`);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.protocol !== "GB32960" || item.vin !== vin) throw new Error(`scope mismatch at ${item.frame_id}`);
|
||||||
|
if (item.ts < dateFrom || item.ts > dateTo) throw new Error(`time out of range at ${item.frame_id}: ${item.ts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
source: `${baseUrl}/api/history/raw-frames`,
|
||||||
|
query: { plate, vin, protocol: "GB32960", dateFrom, dateTo, orderBy: "eventTime" },
|
||||||
|
verification: {
|
||||||
|
apiTotal: total,
|
||||||
|
fetchedRows: items.length,
|
||||||
|
uniqueFrameIds: frameIds.size,
|
||||||
|
uniqueEventIds: eventIds.size,
|
||||||
|
earliestTs: items.reduce((min, item) => !min || item.ts < min ? item.ts : min, ""),
|
||||||
|
latestTs: items.reduce((max, item) => !max || item.ts > max ? item.ts : max, ""),
|
||||||
|
parsedFieldCount: fieldKeys.length,
|
||||||
|
messageCounts,
|
||||||
|
},
|
||||||
|
fieldKeys,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await fs.writeFile(outputPath, JSON.stringify(output), "utf8");
|
||||||
|
console.log(JSON.stringify({ outputPath, ...output.verification }, null, 2));
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const workbookPath = process.env.GB32960_WORKBOOK_PATH || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/gb32960-export-20260817-agp4377/粤AGP4377_2026-08-17_GB32960完整数据_中文字段.xlsx";
|
||||||
|
const lastDataRow = Number(process.env.GB32960_LAST_ROW || "1233");
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(workbookPath));
|
||||||
|
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 3000 });
|
||||||
|
const summary = await workbook.inspect({ kind: "table", range: "导出说明!A3:B15", include: "values,formulas", tableMaxRows: 20, tableMaxCols: 3 });
|
||||||
|
const detail = await workbook.inspect({ kind: "table", range: "完整数据!A1:T6", include: "values,formulas", tableMaxRows: 6, tableMaxCols: 20 });
|
||||||
|
const tail = await workbook.inspect({ kind: "table", range: `完整数据!A${lastDataRow - 4}:F${lastDataRow}`, include: "values,formulas", tableMaxRows: 6, tableMaxCols: 6 });
|
||||||
|
const mapping = await workbook.inspect({ kind: "table", range: "字段映射!A1:G10", include: "values,formulas", tableMaxRows: 10, tableMaxCols: 7 });
|
||||||
|
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "reopened workbook formula error scan" });
|
||||||
|
const output = { sheets: sheets.ndjson, summary: summary.ndjson, detail: detail.ndjson, tail: tail.ndjson, mapping: mapping.ndjson, errors: errors.ndjson };
|
||||||
|
await fs.writeFile(new URL("./reopen-verification.json", import.meta.url), JSON.stringify(output, null, 2), "utf8");
|
||||||
|
console.log(JSON.stringify({ workbookPath, sizeBytes: (await fs.stat(workbookPath)).size, output }, null, 2));
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const sourceDir =
|
||||||
|
"/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026/07/27";
|
||||||
|
const workDir =
|
||||||
|
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gps-mileage-import-019fa1ac-db39-7933-bb0e-30dd63cc25bd";
|
||||||
|
|
||||||
|
const names = (await fs.readdir(sourceDir))
|
||||||
|
.filter((name) => name.endsWith(".xlsx"))
|
||||||
|
.sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||||||
|
|
||||||
|
const manifest = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const filePath = path.join(sourceDir, name);
|
||||||
|
const input = await FileBlob.load(filePath);
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(input);
|
||||||
|
const sheet = workbook.worksheets.getItemAt(0);
|
||||||
|
const usedRange = sheet.getUsedRange(true);
|
||||||
|
const values = usedRange?.values ?? [];
|
||||||
|
const rowCount = values.length;
|
||||||
|
const colCount = values.reduce((max, row) => Math.max(max, row.length), 0);
|
||||||
|
const topRows = values.slice(0, 8).map((row) =>
|
||||||
|
row.slice(0, Math.min(colCount, 40)).map((value) => {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
return value ?? null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const preview = await workbook.render({
|
||||||
|
sheetName: sheet.name,
|
||||||
|
range: `A1:${columnName(Math.min(Math.max(colCount, 1), 40))}${Math.min(
|
||||||
|
Math.max(rowCount, 1),
|
||||||
|
15,
|
||||||
|
)}`,
|
||||||
|
scale: 1,
|
||||||
|
format: "png",
|
||||||
|
});
|
||||||
|
const previewName = `${String(manifest.length + 1).padStart(2, "0")}-${safeName(
|
||||||
|
name,
|
||||||
|
)}.png`;
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workDir, previewName),
|
||||||
|
new Uint8Array(await preview.arrayBuffer()),
|
||||||
|
);
|
||||||
|
manifest.push({
|
||||||
|
name,
|
||||||
|
sheet: sheet.name,
|
||||||
|
rowCount,
|
||||||
|
colCount,
|
||||||
|
firstRow: topRows[0] ?? [],
|
||||||
|
topRows,
|
||||||
|
preview: previewName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workDir, "source-manifest.json"),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
manifest.map(({ name, sheet, rowCount, colCount, firstRow, preview }) => ({
|
||||||
|
name,
|
||||||
|
sheet,
|
||||||
|
rowCount,
|
||||||
|
colCount,
|
||||||
|
firstRow,
|
||||||
|
preview,
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function safeName(name) {
|
||||||
|
return name
|
||||||
|
.replace(/\.xlsx$/i, "")
|
||||||
|
.replace(/[^\p{L}\p{N}._-]+/gu, "_")
|
||||||
|
.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnName(index) {
|
||||||
|
let value = index;
|
||||||
|
let result = "";
|
||||||
|
while (value > 0) {
|
||||||
|
value -= 1;
|
||||||
|
result = String.fromCharCode(65 + (value % 26)) + result;
|
||||||
|
value = Math.floor(value / 26);
|
||||||
|
}
|
||||||
|
return result || "A";
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import fsSync from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import readline from "node:readline";
|
||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const sourceDir =
|
||||||
|
"/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026/07/27";
|
||||||
|
const workDir =
|
||||||
|
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gps-mileage-import-019fa1ac-db39-7933-bb0e-30dd63cc25bd";
|
||||||
|
const priorCsv =
|
||||||
|
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/g7-mileage-history-20260724/merged/G7车辆每日里程_20220101-20260720.csv";
|
||||||
|
const normalizedCsv = path.join(
|
||||||
|
workDir,
|
||||||
|
"G7_GPS车辆每日里程_20251231-20260630.csv",
|
||||||
|
);
|
||||||
|
const combinedCsv = path.join(
|
||||||
|
workDir,
|
||||||
|
"G7_GPS车辆每日里程_合并优先季度统计_20251231-20260630.csv",
|
||||||
|
);
|
||||||
|
const statisticCsv = path.join(
|
||||||
|
workDir,
|
||||||
|
"G7_GPS季度统计每日里程_20260101-20260630.csv",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyNames = [
|
||||||
|
"智能管车_车辆里程日报-091555330.xlsx",
|
||||||
|
"智能管车_车辆里程日报-090938059.xlsx",
|
||||||
|
"智能管车_车辆里程日报-091002580.xlsx",
|
||||||
|
"智能管车_车辆里程日报-091020404.xlsx",
|
||||||
|
"智能管车_车辆里程日报-091030619.xlsx",
|
||||||
|
"智能管车_车辆里程日报-091046118.xlsx",
|
||||||
|
"智能管车_车辆里程日报-091058580.xlsx",
|
||||||
|
];
|
||||||
|
const statisticNames = [
|
||||||
|
"里程统计[天][2026-01-01至2026-03-31] (1).xlsx",
|
||||||
|
"里程统计[天][2026-01-01至2026-03-31].xlsx",
|
||||||
|
"里程统计[天][2026-04-01至2026-06-30] (1).xlsx",
|
||||||
|
"里程统计[天][2026-04-01至2026-06-30].xlsx",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
const newByKey = new Map();
|
||||||
|
const dailyFiles = [];
|
||||||
|
for (const name of dailyNames) {
|
||||||
|
const values = await readFirstSheetValues(path.join(sourceDir, name));
|
||||||
|
const headers = values[0] ?? [];
|
||||||
|
const runtimeIndex = headers.indexOf("运行时长");
|
||||||
|
assert(runtimeIndex > 3, `${name} 缺少运行时长列`);
|
||||||
|
const dateHeaders = headers.slice(3, runtimeIndex);
|
||||||
|
const dates = dateHeaders.map((header) => parseHeaderDate(header));
|
||||||
|
const seenPlates = new Set();
|
||||||
|
let statedTotalKm = 0;
|
||||||
|
let calculatedTotalKm = 0;
|
||||||
|
let mismatchRows = 0;
|
||||||
|
for (const row of values.slice(1)) {
|
||||||
|
const plate = cleanText(row?.[0]);
|
||||||
|
if (!plate) continue;
|
||||||
|
assert(!seenPlates.has(plate), `${name} 存在重复车牌 ${plate}`);
|
||||||
|
seenPlates.add(plate);
|
||||||
|
const stated = numberValue(row?.[2], 10_000_000);
|
||||||
|
const dailyValues = row
|
||||||
|
.slice(3, runtimeIndex)
|
||||||
|
.map((value) => numberValue(value));
|
||||||
|
const calculated = dailyValues.reduce((sum, value) => sum + value, 0);
|
||||||
|
statedTotalKm += stated;
|
||||||
|
calculatedTotalKm += calculated;
|
||||||
|
if (Math.abs(stated - calculated) > 0.011) mismatchRows += 1;
|
||||||
|
for (let index = 0; index < dates.length; index += 1) {
|
||||||
|
const date = dates[index];
|
||||||
|
const mileage = dailyValues[index];
|
||||||
|
const key = `${plate}|${date}`;
|
||||||
|
assert(!newByKey.has(key), `日报重复车辆日 ${key}`);
|
||||||
|
const normalized = { plate, date, mileage };
|
||||||
|
newByKey.set(key, normalized);
|
||||||
|
rows.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dailyFiles.push({
|
||||||
|
name,
|
||||||
|
vehicleCount: seenPlates.size,
|
||||||
|
dateFrom: dates[0],
|
||||||
|
dateTo: dates.at(-1),
|
||||||
|
dateCount: dates.length,
|
||||||
|
rowCount: seenPlates.size * dates.length,
|
||||||
|
statedTotalKm: round(statedTotalKm),
|
||||||
|
calculatedTotalKm: round(calculatedTotalKm),
|
||||||
|
mismatchRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
normalizedCsv,
|
||||||
|
`\uFEFFplate,date,daily_mileage_km\n${rows
|
||||||
|
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
|
||||||
|
.join("\n")}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const statisticByKey = new Map();
|
||||||
|
const statisticFiles = [];
|
||||||
|
for (const name of statisticNames) {
|
||||||
|
const values = await readFirstSheetValues(path.join(sourceDir, name));
|
||||||
|
const headers = (values[1] ?? []).map(cleanText);
|
||||||
|
const plateIndex = headers.indexOf("车牌号码");
|
||||||
|
const methodIndex = headers.indexOf("里程计算方式");
|
||||||
|
const dateIndex = headers.indexOf("日期");
|
||||||
|
const mileageIndex = headers.indexOf("里程(km)");
|
||||||
|
assert(
|
||||||
|
[plateIndex, methodIndex, dateIndex, mileageIndex].every((index) => index >= 0),
|
||||||
|
`${name} 表头不完整`,
|
||||||
|
);
|
||||||
|
const fileKeys = new Set();
|
||||||
|
const methods = new Set();
|
||||||
|
let totalKm = 0;
|
||||||
|
for (const row of values.slice(2)) {
|
||||||
|
const plate = cleanText(row?.[plateIndex]);
|
||||||
|
const date = isoDate(row?.[dateIndex]);
|
||||||
|
if (!plate || !date) continue;
|
||||||
|
const method = cleanText(row?.[methodIndex]);
|
||||||
|
const mileage = numberValue(row?.[mileageIndex]);
|
||||||
|
const key = `${plate}|${date}`;
|
||||||
|
assert(!fileKeys.has(key), `${name} 文件内重复车辆日 ${key}`);
|
||||||
|
assert(!statisticByKey.has(key), `季度统计跨文件重复车辆日 ${key}`);
|
||||||
|
fileKeys.add(key);
|
||||||
|
statisticByKey.set(key, { plate, date, mileage, method, name });
|
||||||
|
methods.add(method);
|
||||||
|
totalKm += mileage;
|
||||||
|
}
|
||||||
|
statisticFiles.push({
|
||||||
|
name,
|
||||||
|
rowCount: fileKeys.size,
|
||||||
|
vehicleCount: new Set([...fileKeys].map((key) => key.split("|")[0])).size,
|
||||||
|
dateCount: new Set([...fileKeys].map((key) => key.split("|")[1])).size,
|
||||||
|
methods: [...methods].sort(),
|
||||||
|
totalKm: round(totalKm),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let statisticMatched = 0;
|
||||||
|
let statisticMissingInDaily = 0;
|
||||||
|
let statisticChanged = 0;
|
||||||
|
let statisticAbsoluteDifferenceKm = 0;
|
||||||
|
let statisticMaxDifferenceKm = 0;
|
||||||
|
const statisticExamples = [];
|
||||||
|
for (const [key, statistic] of statisticByKey) {
|
||||||
|
const daily = newByKey.get(key);
|
||||||
|
if (!daily) {
|
||||||
|
statisticMissingInDaily += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
statisticMatched += 1;
|
||||||
|
const difference = daily.mileage - statistic.mileage;
|
||||||
|
const absolute = Math.abs(difference);
|
||||||
|
statisticAbsoluteDifferenceKm += absolute;
|
||||||
|
statisticMaxDifferenceKm = Math.max(statisticMaxDifferenceKm, absolute);
|
||||||
|
if (absolute > 0.011) {
|
||||||
|
statisticChanged += 1;
|
||||||
|
if (statisticExamples.length < 20) {
|
||||||
|
statisticExamples.push({
|
||||||
|
key,
|
||||||
|
dailyKm: daily.mileage,
|
||||||
|
statisticKm: statistic.mileage,
|
||||||
|
differenceKm: round(difference),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statisticRows = [...statisticByKey.values()].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
statisticCsv,
|
||||||
|
`\uFEFFplate,date,daily_mileage_km\n${statisticRows
|
||||||
|
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
|
||||||
|
.join("\n")}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const combinedByKey = new Map(newByKey);
|
||||||
|
for (const [key, statistic] of statisticByKey) {
|
||||||
|
combinedByKey.set(key, {
|
||||||
|
plate: statistic.plate,
|
||||||
|
date: statistic.date,
|
||||||
|
mileage: statistic.mileage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const combinedRows = [...combinedByKey.values()].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
combinedCsv,
|
||||||
|
`\uFEFFplate,date,daily_mileage_km\n${combinedRows
|
||||||
|
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
|
||||||
|
.join("\n")}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const priorByKey = await loadPriorRange(
|
||||||
|
priorCsv,
|
||||||
|
"2025-12-31",
|
||||||
|
"2026-06-30",
|
||||||
|
);
|
||||||
|
let priorUnchanged = 0;
|
||||||
|
let priorChanged = 0;
|
||||||
|
let priorMissing = 0;
|
||||||
|
let priorExtra = 0;
|
||||||
|
let priorTotalDifferenceKm = 0;
|
||||||
|
let priorMaxDifferenceKm = 0;
|
||||||
|
const priorExamples = [];
|
||||||
|
for (const [key, row] of newByKey) {
|
||||||
|
const prior = priorByKey.get(key);
|
||||||
|
if (!prior) {
|
||||||
|
priorMissing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const difference = row.mileage - prior.mileage;
|
||||||
|
const absolute = Math.abs(difference);
|
||||||
|
priorTotalDifferenceKm += difference;
|
||||||
|
priorMaxDifferenceKm = Math.max(priorMaxDifferenceKm, absolute);
|
||||||
|
if (absolute <= 0.0005) {
|
||||||
|
priorUnchanged += 1;
|
||||||
|
} else {
|
||||||
|
priorChanged += 1;
|
||||||
|
if (priorExamples.length < 20) {
|
||||||
|
priorExamples.push({
|
||||||
|
key,
|
||||||
|
newKm: row.mileage,
|
||||||
|
priorKm: prior.mileage,
|
||||||
|
differenceKm: round(difference),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of priorByKey.keys()) {
|
||||||
|
if (!newByKey.has(key)) priorExtra += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
normalizedCsv,
|
||||||
|
combinedCsv,
|
||||||
|
statisticCsv,
|
||||||
|
source: {
|
||||||
|
rowCount: rows.length,
|
||||||
|
vehicleCount: new Set(rows.map((row) => row.plate)).size,
|
||||||
|
dateCount: new Set(rows.map((row) => row.date)).size,
|
||||||
|
dateFrom: rows.reduce(
|
||||||
|
(min, row) => (!min || row.date < min ? row.date : min),
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
dateTo: rows.reduce((max, row) => (row.date > max ? row.date : max), ""),
|
||||||
|
positiveRows: rows.filter((row) => row.mileage > 0).length,
|
||||||
|
zeroRows: rows.filter((row) => row.mileage === 0).length,
|
||||||
|
totalKm: round(rows.reduce((sum, row) => sum + row.mileage, 0)),
|
||||||
|
files: dailyFiles,
|
||||||
|
},
|
||||||
|
quarterlyReconciliation: {
|
||||||
|
rowCount: statisticByKey.size,
|
||||||
|
matchedRows: statisticMatched,
|
||||||
|
missingInDailyRows: statisticMissingInDaily,
|
||||||
|
changedRowsOver0011Km: statisticChanged,
|
||||||
|
absoluteDifferenceKm: round(statisticAbsoluteDifferenceKm),
|
||||||
|
maxDifferenceKm: round(statisticMaxDifferenceKm),
|
||||||
|
examples: statisticExamples,
|
||||||
|
files: statisticFiles,
|
||||||
|
},
|
||||||
|
combinedImport: {
|
||||||
|
rowCount: combinedRows.length,
|
||||||
|
vehicleCount: new Set(combinedRows.map((row) => row.plate)).size,
|
||||||
|
dateCount: new Set(combinedRows.map((row) => row.date)).size,
|
||||||
|
dateFrom: combinedRows.reduce(
|
||||||
|
(min, row) => (!min || row.date < min ? row.date : min),
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
dateTo: combinedRows.reduce(
|
||||||
|
(max, row) => (row.date > max ? row.date : max),
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
positiveRows: combinedRows.filter((row) => row.mileage > 0).length,
|
||||||
|
zeroRows: combinedRows.filter((row) => row.mileage === 0).length,
|
||||||
|
totalKm: round(
|
||||||
|
combinedRows.reduce((sum, row) => sum + row.mileage, 0),
|
||||||
|
),
|
||||||
|
precedence:
|
||||||
|
"季度里程统计(明确标注终端里程/经纬度)优先;无季度记录时使用车辆里程日报",
|
||||||
|
},
|
||||||
|
priorImportComparison: {
|
||||||
|
priorRangeRows: priorByKey.size,
|
||||||
|
unchangedRows: priorUnchanged,
|
||||||
|
changedRows: priorChanged,
|
||||||
|
missingInPriorRows: priorMissing,
|
||||||
|
extraPriorRows: priorExtra,
|
||||||
|
totalDifferenceKm: round(priorTotalDifferenceKm),
|
||||||
|
maxDifferenceKm: round(priorMaxDifferenceKm),
|
||||||
|
examples: priorExamples,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workDir, "normalization-manifest.json"),
|
||||||
|
`${JSON.stringify(result, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
async function readFirstSheetValues(file) {
|
||||||
|
const input = await FileBlob.load(file);
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(input);
|
||||||
|
return workbook.worksheets.getItemAt(0).getUsedRange(true)?.values ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPriorRange(file, dateFrom, dateTo) {
|
||||||
|
const result = new Map();
|
||||||
|
const stream = fsSync.createReadStream(file, { encoding: "utf8" });
|
||||||
|
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
let first = true;
|
||||||
|
for await (const line of lines) {
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line) continue;
|
||||||
|
const [plate, date, rawMileage] = line.split(",");
|
||||||
|
if (date < dateFrom || date > dateTo) continue;
|
||||||
|
result.set(`${plate}|${date}`, {
|
||||||
|
plate,
|
||||||
|
date,
|
||||||
|
mileage: numberValue(rawMileage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaderDate(value) {
|
||||||
|
const text = cleanText(value);
|
||||||
|
const match = text.match(/^(\d{2})月(\d{2})日$/);
|
||||||
|
assert(match, `无法解析日报日期表头 ${text}`);
|
||||||
|
const month = Number(match[1]);
|
||||||
|
const year = month === 12 ? 2025 : 2026;
|
||||||
|
return `${year}-${match[1]}-${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(value) {
|
||||||
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||||
|
const text = cleanText(value);
|
||||||
|
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value, max = 2500) {
|
||||||
|
if (value === null || value === undefined || value === "") return 0;
|
||||||
|
const number = Number(String(value).replace(/,/g, ""));
|
||||||
|
assert(Number.isFinite(number) && number >= 0 && number <= max, `非法里程 ${value}`);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
if (Number.isInteger(value)) return String(value);
|
||||||
|
return String(Number(value.toFixed(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
return Math.round((value + Number.EPSILON) * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
const base = 'http://115.29.187.205:20200';
|
||||||
|
const date = '2026-08-25';
|
||||||
|
const metrics = JSON.parse(await fs.readFile(new URL('./daily_metrics.json', import.meta.url), 'utf8'));
|
||||||
|
const candidates = metrics.items
|
||||||
|
.filter(x => String(x.platform_name || '').startsWith('现代') && Number(x.daily_mileage_km || 0) >= 10)
|
||||||
|
.sort((a, b) => Number(b.daily_mileage_km) - Number(a.daily_mileage_km));
|
||||||
|
|
||||||
|
async function one(x) {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
protocol: 'GB32960', vin: x.vin,
|
||||||
|
dateFrom: `${date} 00:00:00`, dateTo: `${date} 23:59:59`,
|
||||||
|
orderBy: 'eventTime', limit: '1', offset: '0', includeFields: 'true',
|
||||||
|
includePayload: 'false', includeTotal: 'true'
|
||||||
|
});
|
||||||
|
const res = await fetch(`${base}/api/history/raw-frames?${q}`);
|
||||||
|
if (!res.ok) throw new Error(`${x.vin}: ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
const f = body.items?.[0]?.parsed_fields || {};
|
||||||
|
const required = [
|
||||||
|
'gb32960.fuel_cell.max_hydrogen_pressure_mpa',
|
||||||
|
'gb32960.fuel_cell.max_hydrogen_temperature_c',
|
||||||
|
'gb32960.vehicle.soc_percent',
|
||||||
|
'gb32960.vehicle.total_mileage_km',
|
||||||
|
'gb32960.vehicle.vehicle_status',
|
||||||
|
'gb32960.vehicle.charge_status'
|
||||||
|
];
|
||||||
|
return {...x, raw_total: Number(body.total || 0), first_has: required.filter(k => f[k] != null).length};
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < candidates.length; i += 12) {
|
||||||
|
const batch = candidates.slice(i, i + 12);
|
||||||
|
out.push(...await Promise.all(batch.map(one)));
|
||||||
|
process.stderr.write(`checked ${Math.min(i + 12, candidates.length)}\n`);
|
||||||
|
}
|
||||||
|
await fs.writeFile(new URL('./candidate_assessment.json', import.meta.url), JSON.stringify(out, null, 2));
|
||||||
|
const totals = out.map(x => x.raw_total).sort((a,b)=>a-b);
|
||||||
|
console.log(JSON.stringify({count: out.length, min: totals[0], median: totals[Math.floor(totals.length/2)], max: totals.at(-1), complete_first_frame: out.filter(x=>x.first_has===6).length, prefixes: Object.entries(Object.groupBy(out, x=>x.vin.slice(0,3))).map(([prefix, rows]) => ({prefix, count: rows.length}))}, null, 2));
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { Workbook, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const root="/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
|
||||||
|
const inputDir=`${root}/tmp/hydrogen-audit-100-real/output-v4`;
|
||||||
|
const outputRoot=`${root}/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083`;
|
||||||
|
const packageDir=`${outputRoot}/100个真实车辆日氢耗核验资料包_V3`;
|
||||||
|
const outputPath=`${packageDir}/100个真实车辆日氢耗人工验算底稿_V3.xlsx`;
|
||||||
|
const previewDir=`${outputRoot}/preview-real-100-v3`;
|
||||||
|
const days=JSON.parse(await fs.readFile(`${inputDir}/daily_results.json`,"utf8"));
|
||||||
|
const C={navy:"#0B3A53",blue:"#0E7490",cyan:"#CDEEF4",pale:"#EAF6F8",white:"#FFFFFF",green:"#DCFCE7",greenText:"#166534",orange:"#FFEDD5",orangeText:"#9A3412",red:"#FEE2E2",redText:"#991B1B"};
|
||||||
|
const coeffA=[0.05888460,-0.06136111,-0.002650473,0.002731125,0.001802374,-0.001150707,0.00009588528,-0.0000001109040,0.0000000001264403],coeffB=[1.325,1.87,2.5,2.8,2.938,3.14,3.37,3.75,4.0],coeffC=[1,1,2,2,2.42,2.63,3,4,5];
|
||||||
|
function col(n){let s="";while(n){n--;s=String.fromCharCode(65+n%26)+s;n=Math.floor(n/26)}return s}
|
||||||
|
function title(sheet,address,text){const r=sheet.getRange(address);r.merge();r.values=[[text]];r.format={fill:C.navy,font:{bold:true,color:C.white,size:16},verticalAlignment:"center",horizontalAlignment:"left"};r.format.rowHeight=34}
|
||||||
|
function header(r){r.format={fill:C.blue,font:{bold:true,color:C.white},verticalAlignment:"center",horizontalAlignment:"center",wrapText:true,borders:{preset:"all",style:"thin",color:"#B8CDD5"}};r.format.rowHeight=34}
|
||||||
|
function body(r){r.format={verticalAlignment:"center",borders:{insideHorizontal:{style:"thin",color:"#DDE7EB"},bottom:{style:"thin",color:"#B8CDD5"}}}}
|
||||||
|
function widths(sheet,arr){arr.forEach((w,i)=>sheet.getRange(`${col(i+1)}:${col(i+1)}`).format.columnWidth=w)}
|
||||||
|
function val(v){return v===undefined||v===null?null:v}
|
||||||
|
function zFormula(p,t){return ["1",...Array.from({length:9},(_,i)=>`'参数与公式'!$B$${26+i}*POWER(100/(${t}+273.15),'参数与公式'!$C$${26+i})*POWER(${p},'参数与公式'!$D$${26+i})`)].join("+")}
|
||||||
|
function massFormula(p,t,v){return `${p}*1000*0.00201588*${v}/(8.314472*(${t}+273.15)*(${zFormula(p,t)}))`}
|
||||||
|
|
||||||
|
const wb=Workbook.create();wb.comments.setSelf({displayName:"车辆数据中台"});
|
||||||
|
const guide=wb.worksheets.add("核验说明"),params=wb.worksheets.add("参数与公式"),summary=wb.worksheets.add("100车辆日结果"),h2Detail=wb.worksheets.add("氢量计算明细"),operation=wb.worksheets.add("运行周期明细"),rawIndex=wb.worksheets.add("原始帧索引"),fields=wb.worksheets.add("原始字段说明");
|
||||||
|
for(const s of [guide,params,summary,h2Detail,operation,rawIndex,fields])s.showGridLines=false;
|
||||||
|
|
||||||
|
title(guide,"A1:H1","100个真实车辆日氢耗人工验算底稿(V3)");guide.getRange("A3:B3").values=[["项目","说明"]];header(guide.getRange("A3:B3"));
|
||||||
|
const statusCounts=Object.fromEntries(["OK","SUSPECT","NO_DATA"].map(k=>[k,days.filter(d=>d.stat.QualityStatus===k).length]));
|
||||||
|
const guideRows=[
|
||||||
|
["样本口径","2026-08-25,固定100个真实车辆日;4.5T普货41辆(380 L)、4.5T冷链59辆(520 L),动力电池均为21.04 kWh。"],
|
||||||
|
["原始数据","沿用原100个样本的232,291条GB/T 32960原始帧,未更换车辆、日期或报文;每车1个CSV.GZ,保留原始HEX及中文映射字段。"],
|
||||||
|
["有效分界点","取消上下电前后60秒窗口和首尾中位数,直接取满足车辆启动、字段完整、压力温度有效且不在外部充电/异常压力记录中的有效分界点。"],
|
||||||
|
["物理用氢量","无加氢:首个有效运行分界点剩余氢量-最后有效运行分界点剩余氢量;有加氢:按加氢事件切段后累计各段氢量下降。充电不切分物理氢量。"],
|
||||||
|
["充电识别","仅当充电状态=1且车辆状态=2时识别为外部充电;每次充电完成后建立新充电周期。"],
|
||||||
|
["纯电里程","每个充电周期若启动时为纯电,仅累计首次连续纯电前缀;混动连续3帧且相邻不超过30秒后,从首帧分界点锁定为混动,直至下一次充电。若周期从混动启动,则纯电里程为0。"],
|
||||||
|
["跨日状态","日界线只用于出报,不重置充电周期。若缺少日初已持久化状态,按已进入混动处理,不虚增日初纯电里程,并标记SUSPECT供复核。"],
|
||||||
|
["SOC修正","只使用各充电周期混动段的状态边界:ΔSOC=末SOC-初SOC;电等效氢=电池容量×ΔSOC÷100÷16。SOC上升为正并从用氢量扣除;SOC下降为负,扣除负值即补回电池放电能量。"],
|
||||||
|
["最终公式","修正耗氢量=物理用氢量-用电等效氢;混动里程=总里程-纯电里程;修正百公里氢耗=修正耗氢量÷混动里程×100。"],
|
||||||
|
["人工核对顺序","①在“100车辆日结果”看系统值与Excel复算差;②在“氢量计算明细”核对加氢分段及压力质量;③在“运行周期明细”核对纯电前缀和混动SOC;④按事件ID回查原始CSV。"],
|
||||||
|
["重算结果",`OK ${statusCounts.OK}辆日;SUSPECT ${statusCounts.SUSPECT}辆日;NO_DATA ${statusCounts.NO_DATA}辆日。SUSPECT不删除结果,保留原因和原始证据供人工判断。`],
|
||||||
|
["算法版本","PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3"]];
|
||||||
|
guide.getRange(`A4:B${3+guideRows.length}`).values=guideRows;body(guide.getRange(`A4:B${3+guideRows.length}`));guide.getRange(`B4:B${3+guideRows.length}`).format.wrapText=true;widths(guide,[24,112]);guide.freezePanes.freezeRows(3);
|
||||||
|
|
||||||
|
title(params,"A1:F1","系统参数、压力—质量公式与业务计算口径");params.getRange("A3:C3").values=[["参数","取值","说明"]];header(params.getRange("A3:C3"));
|
||||||
|
const pRows=[["4.5T普货储氢总容积(L)",380,"中台VIN参数表"],["4.5T冷链储氢总容积(L)",520,"中台VIN参数表"],["动力电池容量(kWh)",21.04,"两类4.5T车辆一致"],["氢气等效电量(kWh/kg)",16,"SOC能量折算"],["上电/下电时间窗口(s)",0,"直接使用有效分界点"],["加氢压升阈值(MPa)",3,"需持续300秒"],["加氢持续时间(s)",300,"5分钟"],["纯电异常压降(MPa)",5,"30分钟窗口"],["纯电异常窗口(s)",1800,"30分钟"],["混动确认连续帧数",3,"避免单帧运行模式抖动"],["混动确认相邻最大间隔(s)",30,"确认后回溯到首个混动帧"],["最低混动里程(km)",10,"不足标记SUSPECT"]];
|
||||||
|
params.getRange(`A4:C${3+pRows.length}`).values=pRows;body(params.getRange(`A4:C${3+pRows.length}`));params.getRange(`B4:B${3+pRows.length}`).format.numberFormat="0.000";
|
||||||
|
params.getRange("A18:F18").merge();params.getRange("A18:F18").values=[["核心公式"]];params.getRange("A18:F18").format={fill:C.cyan,font:{bold:true,color:C.navy}};
|
||||||
|
const formulas=["Z = 1 + Σ[aᵢ × (100/Tₖ)^bᵢ × P^cᵢ]","剩余氢量 m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Z)","ΔSOC = SOC_end − SOC_start;用电等效氢 = 电池容量 × ΔSOC ÷ 100 ÷ 16","修正耗氢量 = 物理用氢量 − 用电等效氢","混动里程 = 总里程 − 纯电里程;百公里氢耗 = 修正耗氢量 ÷ 混动里程 × 100"];
|
||||||
|
for(let i=0;i<formulas.length;i++){const row=19+i;params.getRange(`A${row}:F${row}`).merge();params.getRange(`A${row}:F${row}`).values=[[formulas[i]]];params.getRange(`A${row}:F${row}`).format={fill:i%2?C.white:C.pale,font:{color:C.navy,bold:i===3},wrapText:true}}
|
||||||
|
params.getRange("A25:D25").values=[["系数序号","aᵢ","bᵢ","cᵢ"]];header(params.getRange("A25:D25"));params.getRange("A26:D34").values=coeffA.map((a,i)=>[i+1,a,coeffB[i],coeffC[i]]);body(params.getRange("A26:D34"));params.getRange("B26:D34").format.numberFormat="0.0000000000";
|
||||||
|
wb.comments.addThread({cell:params.getRange("B4")},"普货按中台VIN车型参数取380 L,不与冷链520 L混用。");wb.comments.addThread({cell:params.getRange("B5")},"冷链按中台VIN车型参数取520 L。");wb.comments.addThread({cell:params.getRange("B6")},"4.5T普货和冷链动力电池容量均为21.04 kWh。");wb.comments.addThread({cell:params.getRange("B7")},"业务确认:1 kg氢气等效16 kWh,用于SOC能量折算。");widths(params,[32,18,58,15,15,15]);params.freezePanes.freezeRows(3);
|
||||||
|
|
||||||
|
const hHeaders=["样例","车牌","日期","VIN","车型","氢量段号","分段说明","开始时间","结束时间","开始事件ID","结束事件ID","数据源","储氢容积(L)","起始压力(MPa)","结束压力(MPa)","起始温度(℃)","结束温度(℃)","系统起始氢量(kg)","系统结束氢量(kg)","系统分段用氢(kg)","起始里程(km)","结束里程(km)","区间里程(km)","样本数","质量状态","起始氢量复算","结束氢量复算","起始质量差","结束质量差","分段用氢复算","用氢差值","复算结论"];
|
||||||
|
h2Detail.getRange(`A1:${col(hHeaders.length)}1`).values=[hHeaders];header(h2Detail.getRange(`A1:${col(hHeaders.length)}1`));const hRows=[];for(const d of days)for(const it of d.stat.HydrogenIntervals||[])hRows.push([d.candidate.sample_no,d.candidate.plate,d.candidate.stat_date,d.candidate.vin,d.candidate.model,it.index,it.qualityReason,it.startTime,it.endTime,it.startEventId,it.endEventId,it.source,d.candidate.tank_capacity_l,it.startPressureMpa,it.endPressureMpa,it.startTemperatureC,it.endTemperatureC,it.startMassKg,it.endMassKg,it.rawHydrogenConsumptionKg,val(it.startMileageKm),val(it.endMileageKm),val(it.mileageKm),it.sampleCount,it.qualityStatus]);
|
||||||
|
h2Detail.getRange(`A2:Y${hRows.length+1}`).values=hRows;for(let r=2;r<=hRows.length+1;r++){h2Detail.getRange(`Z${r}`).formulas=[[`=${massFormula(`N${r}`,`P${r}`,`M${r}`)}`]];h2Detail.getRange(`AA${r}`).formulas=[[`=${massFormula(`O${r}`,`Q${r}`,`M${r}`)}`]];h2Detail.getRange(`AB${r}`).formulas=[[`=Z${r}-R${r}`]];h2Detail.getRange(`AC${r}`).formulas=[[`=AA${r}-S${r}`]];h2Detail.getRange(`AD${r}`).formulas=[[`=Z${r}-AA${r}`]];h2Detail.getRange(`AE${r}`).formulas=[[`=AD${r}-T${r}`]];h2Detail.getRange(`AF${r}`).formulas=[[`=IF(MAX(ABS(AB${r}),ABS(AC${r}),ABS(AE${r}))<=0.002,"一致","复核")`]]}
|
||||||
|
body(h2Detail.getRange(`A2:AF${hRows.length+1}`));h2Detail.getRange(`M2:AE${hRows.length+1}`).format.numberFormat="0.000000";h2Detail.getRange(`Y2:Y${hRows.length+1}`).conditionalFormats.add("containsText",{text:"SUSPECT",format:{fill:C.orange,font:{color:C.orangeText,bold:true}}});h2Detail.getRange(`AF2:AF${hRows.length+1}`).conditionalFormats.add("containsText",{text:"一致",format:{fill:C.green,font:{color:C.greenText,bold:true}}});h2Detail.getRange(`AF2:AF${hRows.length+1}`).conditionalFormats.add("containsText",{text:"复核",format:{fill:C.red,font:{color:C.redText,bold:true}}});h2Detail.tables.add(`A1:AF${hRows.length+1}`,true,"HydrogenEvidenceV3").style="TableStyleMedium2";h2Detail.freezePanes.freezeRows(1);h2Detail.freezePanes.freezeColumns(7);widths(h2Detail,[8,13,13,22,24,10,34,22,22,34,34,24,14,14,14,14,14,17,17,17,15,15,15,10,13,17,17,14,14,17,14,12]);
|
||||||
|
|
||||||
|
const oHeaders=["样例","车牌","日期","VIN","车型","充电周期号","区间类型","区间号","开始时间","结束时间","开始事件ID","结束事件ID","数据源","储氢容积(L)","电池容量(kWh)","起始压力(MPa)","结束压力(MPa)","起始温度(℃)","结束温度(℃)","系统起始氢量","系统结束氢量","区间物理氢量","起始SOC","结束SOC","系统ΔSOC(末-初)","系统电量变化(kWh)","系统电等效氢(kg)","系统段修正氢量","起始里程","结束里程","系统区间里程","系统段百公里","样本数","质量状态","分界说明","起始氢量复算","结束氢量复算","起始质量差","结束质量差","ΔSOC复算","电量变化复算","电等效氢复算","段修正氢量复算","里程复算","纯电里程贡献","混动电等效氢贡献","复算结论"];
|
||||||
|
operation.getRange(`A1:${col(oHeaders.length)}1`).values=[oHeaders];header(operation.getRange(`A1:${col(oHeaders.length)}1`));const oRows=[];for(const d of days){let cycleNo=0,previous="";for(const it of d.stat.Intervals||[]){if(cycleNo===0||it.type==="PURE_ELECTRIC"||previous==="MIXED")cycleNo++;oRows.push([d.candidate.sample_no,d.candidate.plate,d.candidate.stat_date,d.candidate.vin,d.candidate.model,cycleNo,it.type,it.index,it.startTime,it.endTime,it.startEventId,it.endEventId,it.source,d.candidate.tank_capacity_l,21.04,it.startPressureMpa,it.endPressureMpa,it.startTemperatureC,it.endTemperatureC,it.startMassKg,it.endMassKg,it.rawHydrogenConsumptionKg,val(it.startSocPercent),val(it.endSocPercent),val(it.batterySocDeltaPct),val(it.batteryEnergyChangeKWh),val(it.electricEquivalentHydrogenKg),val(it.correctedHydrogenConsumptionKg),val(it.startMileageKm),val(it.endMileageKm),val(it.mileageKm),val(it.consumptionKgPer100Km),it.sampleCount,it.qualityStatus,it.qualityReason]);previous=it.type}}
|
||||||
|
operation.getRange(`A2:AI${oRows.length+1}`).values=oRows;for(let r=2;r<=oRows.length+1;r++){operation.getRange(`AJ${r}`).formulas=[[`=${massFormula(`P${r}`,`R${r}`,`N${r}`)}`]];operation.getRange(`AK${r}`).formulas=[[`=${massFormula(`Q${r}`,`S${r}`,`N${r}`)}`]];operation.getRange(`AL${r}`).formulas=[[`=AJ${r}-T${r}`]];operation.getRange(`AM${r}`).formulas=[[`=AK${r}-U${r}`]];operation.getRange(`AN${r}`).formulas=[[`=IF(OR(W${r}="",X${r}=""),"",X${r}-W${r})`]];operation.getRange(`AO${r}`).formulas=[[`=IF(AN${r}="","",O${r}*AN${r}/100)`]];operation.getRange(`AP${r}`).formulas=[[`=IF(AO${r}="","",AO${r}/'参数与公式'!$B$7)`]];operation.getRange(`AQ${r}`).formulas=[[`=IF(G${r}="MIXED",V${r}-AP${r},"")`]];operation.getRange(`AR${r}`).formulas=[[`=IF(OR(AC${r}="",AD${r}=""),"",AD${r}-AC${r})`]];operation.getRange(`AS${r}`).formulas=[[`=IF(G${r}="PURE_ELECTRIC",AR${r},0)`]];operation.getRange(`AT${r}`).formulas=[[`=IF(G${r}="MIXED",AP${r},0)`]];operation.getRange(`AU${r}`).formulas=[[`=IF(MAX(ABS(AL${r}),ABS(AM${r}),ABS(AN${r}-Y${r}),ABS(AP${r}-AA${r}),ABS(AR${r}-AE${r}))<=0.002,"一致","复核")`]]}
|
||||||
|
body(operation.getRange(`A2:AU${oRows.length+1}`));operation.getRange(`N2:AT${oRows.length+1}`).format.numberFormat="0.000000";operation.getRange(`G2:G${oRows.length+1}`).conditionalFormats.add("containsText",{text:"PURE_ELECTRIC",format:{fill:C.cyan,font:{color:C.navy}}});operation.getRange(`G2:G${oRows.length+1}`).conditionalFormats.add("containsText",{text:"MIXED",format:{fill:C.green,font:{color:C.greenText}}});operation.getRange(`AU2:AU${oRows.length+1}`).conditionalFormats.add("containsText",{text:"一致",format:{fill:C.green,font:{color:C.greenText,bold:true}}});operation.getRange(`AU2:AU${oRows.length+1}`).conditionalFormats.add("containsText",{text:"复核",format:{fill:C.red,font:{color:C.redText,bold:true}}});operation.tables.add(`A1:AU${oRows.length+1}`,true,"OperationCyclesV3").style="TableStyleMedium2";operation.freezePanes.freezeRows(1);operation.freezePanes.freezeColumns(8);widths(operation,[8,13,13,22,24,11,15,9,22,22,34,34,24,14,15,14,14,14,14,16,16,16,12,12,15,17,17,18,15,15,16,17,10,13,42,17,17,14,14,15,17,17,18,15,16,20,12]);
|
||||||
|
|
||||||
|
const sHeaders=["样例","日期","车牌","VIN","车型","储氢容积(L)","电池容量(kWh)","原统计日里程(km)","有效起始仪表里程","有效结束仪表里程","总里程复算","API原始帧","算法样本","氢量分段数","运行区间数","加氢次数","充电次数","异常记录","系统物理用氢","物理用氢复算","物理差值","系统ΔSOC","ΔSOC复算","系统电等效氢","电等效氢复算","等效氢差值","系统修正耗氢","修正耗氢复算","修正差值","系统纯电里程","纯电里程复算","系统混动里程","混动里程复算","混动差值","系统物理百公里","物理百公里复算","系统修正百公里","修正百公里复算","百公里差值","质量状态","质量原因","算法版本","原始文件","打开原始帧"];
|
||||||
|
summary.getRange(`A1:${col(sHeaders.length)}1`).values=[sHeaders];header(summary.getRange(`A1:${col(sHeaders.length)}1`));
|
||||||
|
const sRows=days.map(d=>{const s=d.stat,c=d.candidate;return[c.sample_no,c.stat_date,c.plate,c.vin,c.model,c.tank_capacity_l,21.04,c.daily_mileage_km,val(s.FirstObservation?.MileageKm),val(s.LastObservation?.MileageKm),null,d.apiRawFrameCount,d.algorithmSamples,(s.HydrogenIntervals||[]).length,(s.Intervals||[]).length,s.RefuelCount,s.ChargeCount,s.InvalidSegmentCount+s.AbnormalDropCount,s.ConsumptionKg,null,null,val(s.BatterySOCDeltaPct),null,val(s.ElectricEquivalentKg),null,null,val(s.CorrectedConsumptionKg),null,null,s.PureElectricMileageKm,null,s.MixedMileageKm,null,null,val(s.ConsumptionKgPer100Km),null,val(s.SOCBalancedKgPer100Km),null,null,s.QualityStatus,s.QualityReason,s.CalculationParameters?.algorithmVersion,d.rawArchiveFile,null]});
|
||||||
|
summary.getRange(`A2:AR${sRows.length+1}`).values=sRows;const hEnd=hRows.length+1,oEnd=oRows.length+1;
|
||||||
|
for(let r=2;r<=sRows.length+1;r++){
|
||||||
|
summary.getRange(`K${r}`).formulas=[[`=IF(OR(I${r}="",J${r}=""),"",J${r}-I${r})`]];
|
||||||
|
summary.getRange(`T${r}`).formulas=[[`=SUMIFS('氢量计算明细'!$AD$2:$AD$${hEnd},'氢量计算明细'!$D$2:$D$${hEnd},$D${r})`]];summary.getRange(`U${r}`).formulas=[[`=T${r}-S${r}`]];
|
||||||
|
summary.getRange(`W${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AN$2:$AN$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r},'运行周期明细'!$G$2:$G$${oEnd},"MIXED")`]];
|
||||||
|
summary.getRange(`Y${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AT$2:$AT$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r})`]];summary.getRange(`Z${r}`).formulas=[[`=Y${r}-X${r}`]];
|
||||||
|
summary.getRange(`AB${r}`).formulas=[[`=T${r}-Y${r}`]];summary.getRange(`AC${r}`).formulas=[[`=AB${r}-AA${r}`]];
|
||||||
|
summary.getRange(`AE${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AS$2:$AS$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r})`]];
|
||||||
|
summary.getRange(`AG${r}`).formulas=[[`=K${r}-AE${r}`]];summary.getRange(`AH${r}`).formulas=[[`=AG${r}-AF${r}`]];
|
||||||
|
summary.getRange(`AJ${r}`).formulas=[[`=IF(AG${r}>0,T${r}*100/AG${r},"")`]];summary.getRange(`AL${r}`).formulas=[[`=IF(AG${r}>0,AB${r}*100/AG${r},"")`]];summary.getRange(`AM${r}`).formulas=[[`=IF(OR(AK${r}="",AL${r}=""),"",AL${r}-AK${r})`]];
|
||||||
|
summary.getRange(`AR${r}`).formulas=[[`=HYPERLINK("${days[r-2].rawArchiveFile}","打开")`]];
|
||||||
|
}
|
||||||
|
body(summary.getRange(`A2:AR${sRows.length+1}`));summary.getRange(`F2:AM${sRows.length+1}`).format.numberFormat="0.000000";
|
||||||
|
for(const [text,fill,font] of [["OK",C.green,C.greenText],["SUSPECT",C.orange,C.orangeText],["NO_DATA",C.red,C.redText]])summary.getRange(`AN2:AN${sRows.length+1}`).conditionalFormats.add("containsText",{text,format:{fill,font:{color:font,bold:true}}});
|
||||||
|
for(const c of ["U","Z","AC","AH","AM"]){summary.getRange(`${c}2:${c}${sRows.length+1}`).conditionalFormats.add("cellIs",{operator:"greaterThan",formula:0.002,format:{fill:C.red,font:{color:C.redText,bold:true}}});summary.getRange(`${c}2:${c}${sRows.length+1}`).conditionalFormats.add("cellIs",{operator:"lessThan",formula:-0.002,format:{fill:C.red,font:{color:C.redText,bold:true}}})}
|
||||||
|
summary.tables.add(`A1:AR${sRows.length+1}`,true,"VehicleDayResultsV3").style="TableStyleMedium2";summary.freezePanes.freezeRows(1);summary.freezePanes.freezeColumns(5);widths(summary,[8,13,13,22,24,14,14,17,18,18,15,12,12,12,12,11,11,12,16,16,14,14,14,16,16,14,17,17,14,16,16,16,16,14,18,18,18,18,14,13,48,42,58,12]);
|
||||||
|
|
||||||
|
const rHeaders=["样例","车牌","VIN","日期","车型","储氢容积(L)","电池容量(kWh)","API原始帧数","唯一帧数","重复帧数","算法样本数","关键字段帧数","原始最早事件时间","原始最晚事件时间","原始文件","打开"];
|
||||||
|
rawIndex.getRange("A1:P1").values=[rHeaders];header(rawIndex.getRange("A1:P1"));const rRows=days.map(d=>[d.candidate.sample_no,d.candidate.plate,d.candidate.vin,d.candidate.stat_date,d.candidate.model,d.candidate.tank_capacity_l,21.04,d.apiRawFrameCount,d.uniqueFrameCount,d.duplicateFrameCount,d.algorithmSamples,d.criticalFieldRows,d.earliestEventTime,d.latestEventTime,d.rawArchiveFile,null]);rawIndex.getRange(`A2:P${rRows.length+1}`).values=rRows;for(let r=2;r<=rRows.length+1;r++)rawIndex.getRange(`P${r}`).formulas=[[`=HYPERLINK("${days[r-2].rawArchiveFile}","打开")`]];body(rawIndex.getRange(`A2:P${rRows.length+1}`));rawIndex.tables.add(`A1:P${rRows.length+1}`,true,"RawFrameManifestV3").style="TableStyleMedium2";rawIndex.freezePanes.freezeRows(1);rawIndex.freezePanes.freezeColumns(5);widths(rawIndex,[8,13,22,13,24,14,14,14,12,12,14,15,21,21,58,12]);
|
||||||
|
|
||||||
|
title(fields,"A1:D1","原始帧字段及人工核验用途");fields.getRange("A3:D3").values=[["中文字段","GB/T 32960解析键/原始属性","用途","校验规则/枚举"]];header(fields.getRange("A3:D3"));
|
||||||
|
const fRows=[["原始报文HEX","raw_hex","回查原始帧","完整保留,不参与公式"],["事件ID","event_id","连接各明细起止事件","按ID在对应CSV查找"],["事件时间","event_time","确定自然日及排序","以事件时间为准"],["最高氢压(MPa)","gb32960.fuel_cell.max_hydrogen_pressure_mpa","压力—质量换算","0<P≤70"],["最高氢温(℃)","gb32960.fuel_cell.max_hydrogen_temperature_c","压力—质量换算","T>-40"],["电池SOC(%)","gb32960.vehicle.soc_percent","混动边界SOC修正","0–100;只取相关状态边界"],["仪表总里程(km)","gb32960.vehicle.total_mileage_km","总/纯电/混动里程","分界点末值-初值"],["车辆状态","gb32960.vehicle.vehicle_status","启动及充电联合判断","1=启动,2=熄火,其他按国标保留"],["充电状态","gb32960.vehicle.charge_status","外部充电联合判断","1=停车充电,2=行驶充电,3=未充电,4=充电完成"],["运行模式","gb32960.vehicle.running_mode","纯电前缀/混动锁定","1=纯电,2=混动,3=燃油,254=异常,255=无效"],["燃料电池工作状态","gb32960.gd_fc_stack.engine_work_state","辅助确认纯电/混动","2=工作;0/1=未工作/停机"],["系统压力换算质量(kg)","工作底稿复算列","氢量端点","按VIN容积+NIST公式"],["计算角色/排除原因","工作底稿标注","定位参与/排除状态","有效分界点、外部充电、异常或字段缺失"]];
|
||||||
|
fields.getRange(`A4:D${3+fRows.length}`).values=fRows;body(fields.getRange(`A4:D${3+fRows.length}`));fields.getRange(`A4:D${3+fRows.length}`).format.wrapText=true;widths(fields,[28,62,40,54]);fields.freezePanes.freezeRows(3);
|
||||||
|
|
||||||
|
await fs.mkdir(packageDir,{recursive:true});
|
||||||
|
const checks={};for(const [name,range] of [["核验说明","A1:H15"],["参数与公式","A1:F34"],["100车辆日结果","A1:AR12"],["氢量计算明细","A1:AF15"],["运行周期明细","A1:AU15"],["原始帧索引","A1:P15"],["原始字段说明","A1:D16"]])checks[name]=(await wb.inspect({kind:"table",sheetId:name,range,include:"values,formulas",tableMaxRows:18,tableMaxCols:50,maxChars:18000})).ndjson;
|
||||||
|
const formulaErrors=await wb.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:300},summary:"final formula error scan",maxChars:10000});
|
||||||
|
const xlsx=await SpreadsheetFile.exportXlsx(wb);await xlsx.save(outputPath);
|
||||||
|
await fs.rm(previewDir,{recursive:true,force:true});await fs.mkdir(previewDir,{recursive:true});const renderRanges={"核验说明":"A1:H15","参数与公式":"A1:F34","100车辆日结果":"A1:AR14","氢量计算明细":"A1:AF14","运行周期明细":"A1:AU14","原始帧索引":"A1:P14","原始字段说明":"A1:D16"};for(const [name,range] of Object.entries(renderRanges)){const image=await wb.render({sheetName:name,range,scale:1,format:"png"});await fs.writeFile(`${previewDir}/${name}.png`,new Uint8Array(await image.arrayBuffer()))}
|
||||||
|
await fs.cp(`${inputDir}/raw`,`${packageDir}/raw`,{recursive:true,force:true});for(const name of ["daily_results.json","raw_manifest.csv","selected_vehicles.json"])await fs.copyFile(`${inputDir}/${name}`,`${packageDir}/${name}`);
|
||||||
|
await fs.writeFile(`${outputRoot}/verification-real-100-v3.json`,JSON.stringify({outputPath,checks,formulaErrors:formulaErrors.ndjson,days:days.length,hydrogenIntervals:hRows.length,operationIntervals:oRows.length},null,2));console.log(JSON.stringify({outputPath,days:days.length,hydrogenIntervals:hRows.length,operationIntervals:oRows.length,previewDir,formulaErrors:formulaErrors.ndjson},null,2));
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const path = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/100个真实车辆日氢耗核验资料包_V3/100个真实车辆日氢耗人工验算底稿_V3.xlsx";
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(path));
|
||||||
|
for (const [sheetId, range] of [["100车辆日结果", "A53:AR53"], ["氢量计算明细", "A1:AF4"], ["运行周期明细", "A1:AU8"]]) {
|
||||||
|
const result = await workbook.inspect({kind:"table", sheetId, range, include:"values,formulas", tableMaxRows:10, tableMaxCols:50, maxChars:16000});
|
||||||
|
console.log(result.ndjson);
|
||||||
|
}
|
||||||
|
const errors = await workbook.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:300},summary:"final formula error scan",maxChars:4000});
|
||||||
|
console.log(errors.ndjson);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const inputPath = "/Users/lingniu/Downloads/氢气计算公式.xlsx";
|
||||||
|
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/hydrogen-formula-analysis-20260826/preview";
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(inputPath));
|
||||||
|
const overview = await workbook.inspect({
|
||||||
|
kind: "workbook,sheet,table",
|
||||||
|
include: "id,name,values,formulas",
|
||||||
|
maxChars: 12000,
|
||||||
|
tableMaxRows: 30,
|
||||||
|
tableMaxCols: 20,
|
||||||
|
tableMaxCellChars: 300,
|
||||||
|
});
|
||||||
|
console.log("OVERVIEW");
|
||||||
|
console.log(overview.ndjson);
|
||||||
|
|
||||||
|
const sheets = (await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 6000 })).ndjson
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
.filter((item) => item.kind === "sheet");
|
||||||
|
|
||||||
|
for (let index = 0; index < sheets.length; index += 1) {
|
||||||
|
const sheet = sheets[index];
|
||||||
|
const region = await workbook.inspect({
|
||||||
|
kind: "region,formula,computedStyle",
|
||||||
|
sheetId: sheet.name,
|
||||||
|
range: "A1:Z80",
|
||||||
|
include: "values,formulas",
|
||||||
|
maxChars: 30000,
|
||||||
|
tableMaxRows: 80,
|
||||||
|
tableMaxCols: 26,
|
||||||
|
tableMaxCellChars: 500,
|
||||||
|
options: { maxResults: 300 },
|
||||||
|
});
|
||||||
|
console.log(`SHEET ${sheet.name}`);
|
||||||
|
console.log(region.ndjson);
|
||||||
|
const rendered = await workbook.render({ sheetName: sheet.name, autoCrop: "all", scale: 2, format: "png" });
|
||||||
|
const safeName = String(sheet.name).replace(/[\\/:*?"<>|]/g, "_");
|
||||||
|
await fs.writeFile(`${outputDir}/${String(index + 1).padStart(2, "0")}-${safeName}.png`, new Uint8Array(await rendered.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ inputPath, outputDir, sheetCount: sheets.length }));
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083";
|
||||||
|
const outputPath = `${outputDir}/氢耗算法120组人工验算底稿.xlsx`;
|
||||||
|
const previewDir = `${outputDir}/preview`;
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
navy: "#0B3A53",
|
||||||
|
blue: "#0E7490",
|
||||||
|
cyan: "#CDEEF4",
|
||||||
|
pale: "#EAF6F8",
|
||||||
|
green: "#DCFCE7",
|
||||||
|
greenText: "#166534",
|
||||||
|
orange: "#FFEDD5",
|
||||||
|
orangeText: "#9A3412",
|
||||||
|
red: "#FEE2E2",
|
||||||
|
redText: "#991B1B",
|
||||||
|
gray: "#E5E7EB",
|
||||||
|
grayText: "#475569",
|
||||||
|
white: "#FFFFFF",
|
||||||
|
};
|
||||||
|
|
||||||
|
const coeffA = [0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403];
|
||||||
|
const coeffB = [1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0];
|
||||||
|
const coeffC = [1, 1, 2, 2, 2.42, 2.63, 3, 4, 5];
|
||||||
|
|
||||||
|
function massKg(pressureMPa, temperatureC, volumeLiter) {
|
||||||
|
const temperatureK = temperatureC + 273.15;
|
||||||
|
let z = 1;
|
||||||
|
for (let i = 0; i < coeffA.length; i++) {
|
||||||
|
z += coeffA[i] * Math.pow(100 / temperatureK, coeffB[i]) * Math.pow(pressureMPa, coeffC[i]);
|
||||||
|
}
|
||||||
|
return pressureMPa * 1000 * 0.00201588 * volumeLiter / (8.314472 * temperatureK * z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function excelCol(index) {
|
||||||
|
let result = "";
|
||||||
|
let value = index;
|
||||||
|
while (value > 0) {
|
||||||
|
value--;
|
||||||
|
result = String.fromCharCode(65 + (value % 26)) + result;
|
||||||
|
value = Math.floor(value / 26);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleTitle(sheet, range, title) {
|
||||||
|
range.merge();
|
||||||
|
range.values = [[title]];
|
||||||
|
range.format = {
|
||||||
|
fill: colors.navy,
|
||||||
|
font: { bold: true, color: colors.white },
|
||||||
|
verticalAlignment: "center",
|
||||||
|
horizontalAlignment: "left",
|
||||||
|
};
|
||||||
|
range.format.rowHeight = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleHeader(range) {
|
||||||
|
range.format = {
|
||||||
|
fill: colors.blue,
|
||||||
|
font: { bold: true, color: colors.white },
|
||||||
|
verticalAlignment: "center",
|
||||||
|
horizontalAlignment: "center",
|
||||||
|
wrapText: true,
|
||||||
|
borders: { preset: "all", style: "thin", color: "#B8CDD5" },
|
||||||
|
};
|
||||||
|
range.format.rowHeight = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleBody(range) {
|
||||||
|
range.format = {
|
||||||
|
verticalAlignment: "center",
|
||||||
|
borders: {
|
||||||
|
insideHorizontal: { style: "thin", color: "#DDE7EB" },
|
||||||
|
bottom: { style: "thin", color: "#B8CDD5" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidths(sheet, widths) {
|
||||||
|
widths.forEach((width, index) => {
|
||||||
|
sheet.getRange(`${excelCol(index + 1)}:${excelCol(index + 1)}`).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = Workbook.create();
|
||||||
|
workbook.comments.setSelf({ displayName: "User" });
|
||||||
|
const guide = workbook.worksheets.add("验算说明");
|
||||||
|
const params = workbook.worksheets.add("参数与系数");
|
||||||
|
const grid = workbook.worksheets.add("120组压力质量验算");
|
||||||
|
const raw = workbook.worksheets.add("原始报文120条");
|
||||||
|
const detail = workbook.worksheets.add("样本计算明细");
|
||||||
|
const summary = workbook.worksheets.add("每日与区间汇总");
|
||||||
|
|
||||||
|
for (const sheet of [guide, params, grid, raw, detail, summary]) sheet.showGridLines = false;
|
||||||
|
|
||||||
|
// 参数与NIST系数
|
||||||
|
styleTitle(params, params.getRange("A1:F1"), "氢耗算法参数与NIST实气压缩因子系数");
|
||||||
|
params.getRange("A3:C3").values = [["参数", "取值", "说明"]];
|
||||||
|
styleHeader(params.getRange("A3:C3"));
|
||||||
|
const parameterRows = [
|
||||||
|
["储氢系统总容积(L)", 520, "验算样本使用,可替换为车型确认值"],
|
||||||
|
["动力电池容量(kWh)", 21.04, "4.5T示例;必须使用售后技术部确认值"],
|
||||||
|
["1kg氢气等效电量(kWh/kg)", 16, "当前业务折算参数"],
|
||||||
|
["上电稳定等待(s)", 60, "上电后60秒内样本不作为计算端点"],
|
||||||
|
["下电提前窗口(s)", 60, "下电前60秒样本不作为计算端点"],
|
||||||
|
["加氢压力上升阈值(MPa)", 3, "并需持续达到保持时间"],
|
||||||
|
["加氢保持时间(s)", 300, "5分钟"],
|
||||||
|
["纯电异常压降阈值(MPa)", 5, "30分钟内超过该值判异常"],
|
||||||
|
["纯电异常窗口(s)", 1800, "30分钟"],
|
||||||
|
["端点中位样本数", 5, "区间首尾各5条"],
|
||||||
|
["最少有效样本数", 10, "不足10条不形成有效区间"],
|
||||||
|
["样本间隔(s)", 10, "本验算原始数据间隔"],
|
||||||
|
];
|
||||||
|
params.getRange(`A4:C${3 + parameterRows.length}`).values = parameterRows;
|
||||||
|
styleBody(params.getRange(`A4:C${3 + parameterRows.length}`));
|
||||||
|
params.getRange(`B4:B${3 + parameterRows.length}`).format.numberFormat = "0.000";
|
||||||
|
|
||||||
|
params.getRange("A18:D18").values = [["系数序号", "aᵢ", "bᵢ", "cᵢ"]];
|
||||||
|
styleHeader(params.getRange("A18:D18"));
|
||||||
|
params.getRange("A19:D27").values = coeffA.map((a, index) => [index + 1, a, coeffB[index], coeffC[index]]);
|
||||||
|
styleBody(params.getRange("A19:D27"));
|
||||||
|
params.getRange("B19:D27").format.numberFormat = "0.0000000000";
|
||||||
|
params.getRange("A29:F33").values = [
|
||||||
|
["压力—质量公式", null, null, null, null, null],
|
||||||
|
["Z = 1 + Σ[aᵢ × (100/Tₖ)^bᵢ × P^cᵢ]", null, null, null, null, null],
|
||||||
|
["m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Z)", null, null, null, null, null],
|
||||||
|
["SOC平衡公式", null, null, null, null, null],
|
||||||
|
["m_SOC = m_H₂ + C_battery × (SOC_start − SOC_end) ÷ 100 ÷ 16", null, null, null, null, null],
|
||||||
|
];
|
||||||
|
for (const row of [29, 30, 31, 32, 33]) params.getRange(`A${row}:F${row}`).merge();
|
||||||
|
params.getRange("A29:F29").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
|
||||||
|
params.getRange("A32:F32").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
|
||||||
|
params.getRange("A30:F31").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true };
|
||||||
|
params.getRange("A33:F33").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true };
|
||||||
|
setWidths(params, [28, 18, 52, 16, 16, 16]);
|
||||||
|
params.freezePanes.freezeRows(3);
|
||||||
|
|
||||||
|
// 120组独立压力—质量验算
|
||||||
|
const gridHeaders = ["序号", "压力P(MPa)", "温度T(℃)", "容积V(L)", "绝对温度Tₖ(K)", ...Array.from({ length: 9 }, (_, i) => `Z分项${i + 1}`), "压缩因子Z", "分子", "分母", "Excel复算质量(kg)", "系统计算质量(kg)", "绝对差值(kg)", "核验结果"];
|
||||||
|
grid.getRange(`A1:${excelCol(gridHeaders.length)}1`).values = [gridHeaders];
|
||||||
|
styleHeader(grid.getRange(`A1:${excelCol(gridHeaders.length)}1`));
|
||||||
|
const pressures = [1, 3, 5, 8, 10, 12, 15, 18, 21, 25, 30, 35];
|
||||||
|
const temperatures = [-20, -10, 0, 10, 20, 30, 40, 50, 60, 70];
|
||||||
|
const gridInputs = [];
|
||||||
|
const gridSystem = [];
|
||||||
|
let gridIndex = 1;
|
||||||
|
for (const pressure of pressures) {
|
||||||
|
for (const temperature of temperatures) {
|
||||||
|
gridInputs.push([gridIndex++, pressure, temperature, 520]);
|
||||||
|
gridSystem.push(massKg(pressure, temperature, 520));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grid.getRange("A2:D121").values = gridInputs;
|
||||||
|
for (let row = 2; row <= 121; row++) {
|
||||||
|
grid.getRange(`E${row}`).formulas = [[`=C${row}+273.15`]];
|
||||||
|
for (let term = 0; term < 9; term++) {
|
||||||
|
const col = excelCol(6 + term);
|
||||||
|
const coeffRow = 19 + term;
|
||||||
|
grid.getRange(`${col}${row}`).formulas = [[`='参数与系数'!$B$${coeffRow}*POWER(100/$E${row},'参数与系数'!$C$${coeffRow})*POWER($B${row},'参数与系数'!$D$${coeffRow})`]];
|
||||||
|
}
|
||||||
|
grid.getRange(`O${row}`).formulas = [[`=1+SUM(F${row}:N${row})`]];
|
||||||
|
grid.getRange(`P${row}`).formulas = [[`=B${row}*1000*0.00201588*D${row}`]];
|
||||||
|
grid.getRange(`Q${row}`).formulas = [[`=8.314472*E${row}*O${row}`]];
|
||||||
|
grid.getRange(`R${row}`).formulas = [[`=P${row}/Q${row}`]];
|
||||||
|
grid.getRange(`S${row}`).values = [[gridSystem[row - 2]]];
|
||||||
|
grid.getRange(`T${row}`).formulas = [[`=ABS(R${row}-S${row})`]];
|
||||||
|
grid.getRange(`U${row}`).formulas = [[`=IF(T${row}<=0.0000000001,"通过","不通过")`]];
|
||||||
|
}
|
||||||
|
styleBody(grid.getRange("A2:U121"));
|
||||||
|
grid.getRange("B2:D121").format.numberFormat = "0.000";
|
||||||
|
grid.getRange("E2:Q121").format.numberFormat = "0.0000000000";
|
||||||
|
grid.getRange("R2:S121").format.numberFormat = "0.000000";
|
||||||
|
grid.getRange("T2:T121").format.numberFormat = "0.000000000000";
|
||||||
|
grid.getRange("U2:U121").conditionalFormats.add("containsText", { text: "通过", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
|
||||||
|
grid.getRange("U2:U121").conditionalFormats.add("containsText", { text: "不通过", format: { fill: colors.red, font: { color: colors.redText, bold: true } } });
|
||||||
|
grid.tables.add("A1:U121", true, "NISTValidation120").style = "TableStyleMedium2";
|
||||||
|
grid.freezePanes.freezeRows(1);
|
||||||
|
grid.freezePanes.freezeColumns(5);
|
||||||
|
setWidths(grid, [8, 12, 12, 12, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 16, 16, 18, 18, 18, 12]);
|
||||||
|
|
||||||
|
// 构造120条可人工核验的原始GB/T 32960解析字段样本。
|
||||||
|
const rawHeaders = ["序号", "报文ID", "上报时间", "数据源", "车辆状态", "充电状态", "运行模式", "燃料电池工作", "氢压(MPa)", "氢温(℃)", "储氢容积(L)", "电SOC(%)", "仪表总里程(km)", "原始解析字段JSON"];
|
||||||
|
raw.getRange("A1:N1").values = [rawHeaders];
|
||||||
|
styleHeader(raw.getRange("A1:N1"));
|
||||||
|
const rawRows = [];
|
||||||
|
const systemMasses = [];
|
||||||
|
const baseTime = new Date(Date.UTC(2026, 7, 26, 0, 0, 0));
|
||||||
|
for (let index = 0; index < 120; index++) {
|
||||||
|
const eventId = `audit-32960-${String(index + 1).padStart(3, "0")}`;
|
||||||
|
const observedAt = new Date(baseTime.getTime() + index * 10_000);
|
||||||
|
let vehicleState = 1;
|
||||||
|
let chargeState = 3;
|
||||||
|
let runningMode = 2;
|
||||||
|
let fuelCellActive = 1;
|
||||||
|
let pressure;
|
||||||
|
let soc;
|
||||||
|
let mileage;
|
||||||
|
if (index < 40) {
|
||||||
|
pressure = 30 - index * 0.04;
|
||||||
|
soc = 80 - index * 0.04;
|
||||||
|
mileage = 1000 + index * 0.08;
|
||||||
|
} else if (index < 60) {
|
||||||
|
vehicleState = 2;
|
||||||
|
chargeState = 1;
|
||||||
|
runningMode = 0;
|
||||||
|
fuelCellActive = 0;
|
||||||
|
pressure = 28.4 - (index - 40) * 0.002;
|
||||||
|
soc = 78.4 + (index - 40) * (11.6 / 19);
|
||||||
|
mileage = 1003.2;
|
||||||
|
} else if (index < 80) {
|
||||||
|
chargeState = index === 60 ? 4 : 3;
|
||||||
|
runningMode = 1;
|
||||||
|
fuelCellActive = 0;
|
||||||
|
pressure = 28.36 - (index - 60) * 0.005;
|
||||||
|
soc = 90 - (index - 60) * 0.15;
|
||||||
|
mileage = 1003.2 + (index - 60) * 0.1;
|
||||||
|
} else {
|
||||||
|
pressure = 28.26 - (index - 80) * 0.06;
|
||||||
|
soc = 87 - (index - 80) * 0.04;
|
||||||
|
mileage = 1005.2 + (index - 80) * 0.12;
|
||||||
|
if (index === 119) vehicleState = 2;
|
||||||
|
}
|
||||||
|
const temperature = 30 + Math.sin(index / 10);
|
||||||
|
const mass = massKg(pressure, temperature, 520);
|
||||||
|
systemMasses.push(mass);
|
||||||
|
const rawJson = JSON.stringify({
|
||||||
|
event_id: eventId,
|
||||||
|
vehicle_state: vehicleState,
|
||||||
|
charge_status: chargeState,
|
||||||
|
running_mode: runningMode,
|
||||||
|
fuel_cell_active: fuelCellActive,
|
||||||
|
hydrogen_max_pressure_mpa: Number(pressure.toFixed(6)),
|
||||||
|
hydrogen_max_temperature_c: Number(temperature.toFixed(6)),
|
||||||
|
soc_percent: Number(soc.toFixed(6)),
|
||||||
|
total_mileage_km: Number(mileage.toFixed(6)),
|
||||||
|
});
|
||||||
|
rawRows.push([index + 1, eventId, observedAt, "GB32960-audit-sample", vehicleState, chargeState, runningMode, fuelCellActive, pressure, temperature, 520, soc, mileage, rawJson]);
|
||||||
|
}
|
||||||
|
raw.getRange("A2:N121").values = rawRows;
|
||||||
|
styleBody(raw.getRange("A2:N121"));
|
||||||
|
raw.getRange("C2:C121").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
|
||||||
|
raw.getRange("I2:M121").format.numberFormat = "0.000000";
|
||||||
|
raw.getRange("N2:N121").format.wrapText = false;
|
||||||
|
raw.getRange("A123:N126").values = [
|
||||||
|
["人工核验说明", null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["本页为验算构造的原始解析字段样本,不是生产车辆真实报文。生产复核时应按相同字段结构替换为中台导出的真实32960数据。", null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["车辆状态:1=启动/运行,2=熄火;充电状态:1=停车充电,3=未充电,4=充电完成;运行模式:1=纯电,2=混动。", null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["停车充电需由车辆状态与充电状态联合判断;充电区间不计算氢耗,充电完成重新建立SOC、压力、温度和里程基线。", null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
];
|
||||||
|
for (let row = 123; row <= 126; row++) raw.getRange(`A${row}:N${row}`).merge();
|
||||||
|
raw.getRange("A123:N123").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
|
||||||
|
raw.getRange("A124:N126").format = { fill: colors.pale, font: { color: colors.grayText }, wrapText: true };
|
||||||
|
raw.tables.add("A1:N121", true, "RawTelemetry120").style = "TableStyleMedium2";
|
||||||
|
raw.freezePanes.freezeRows(1);
|
||||||
|
raw.freezePanes.freezeColumns(3);
|
||||||
|
setWidths(raw, [8, 24, 20, 24, 10, 10, 10, 14, 12, 12, 14, 12, 18, 90]);
|
||||||
|
|
||||||
|
// 逐条计算明细:全部中间状态和公式均保留。
|
||||||
|
const detailHeaders = ["序号", "报文ID", "上报时间", "压力P", "温度T", "容积V", "Tₖ", ...Array.from({ length: 9 }, (_, i) => `Z分项${i + 1}`), "Z", "分子", "分母", "Excel复算质量", "系统质量", "差值", "车辆状态", "充电状态", "运行模式", "燃料电池", "SOC", "仪表里程", "阶段", "上电60s通过", "下电60s通过", "停车充电", "状态组合有效", "参与计算", "排除原因", "区间类型", "系统区间号", "端点角色"];
|
||||||
|
detail.getRange(`A1:${excelCol(detailHeaders.length)}1`).values = [detailHeaders];
|
||||||
|
styleHeader(detail.getRange(`A1:${excelCol(detailHeaders.length)}1`));
|
||||||
|
for (let row = 2; row <= 121; row++) {
|
||||||
|
const rawRow = row;
|
||||||
|
const index = row - 2;
|
||||||
|
detail.getRange(`A${row}:F${row}`).formulas = [[
|
||||||
|
`='原始报文120条'!A${rawRow}`,
|
||||||
|
`='原始报文120条'!B${rawRow}`,
|
||||||
|
`='原始报文120条'!C${rawRow}`,
|
||||||
|
`='原始报文120条'!I${rawRow}`,
|
||||||
|
`='原始报文120条'!J${rawRow}`,
|
||||||
|
`='原始报文120条'!K${rawRow}`,
|
||||||
|
]];
|
||||||
|
detail.getRange(`G${row}`).formulas = [[`=E${row}+273.15`]];
|
||||||
|
for (let term = 0; term < 9; term++) {
|
||||||
|
const col = excelCol(8 + term);
|
||||||
|
const coeffRow = 19 + term;
|
||||||
|
detail.getRange(`${col}${row}`).formulas = [[`='参数与系数'!$B$${coeffRow}*POWER(100/$G${row},'参数与系数'!$C$${coeffRow})*POWER($D${row},'参数与系数'!$D$${coeffRow})`]];
|
||||||
|
}
|
||||||
|
detail.getRange(`Q${row}`).formulas = [[`=1+SUM(H${row}:P${row})`]];
|
||||||
|
detail.getRange(`R${row}`).formulas = [[`=D${row}*1000*0.00201588*F${row}`]];
|
||||||
|
detail.getRange(`S${row}`).formulas = [[`=8.314472*G${row}*Q${row}`]];
|
||||||
|
detail.getRange(`T${row}`).formulas = [[`=R${row}/S${row}`]];
|
||||||
|
detail.getRange(`U${row}`).values = [[systemMasses[index]]];
|
||||||
|
detail.getRange(`V${row}`).formulas = [[`=ABS(T${row}-U${row})`]];
|
||||||
|
detail.getRange(`W${row}:AB${row}`).formulas = [[
|
||||||
|
`='原始报文120条'!E${rawRow}`,
|
||||||
|
`='原始报文120条'!F${rawRow}`,
|
||||||
|
`='原始报文120条'!G${rawRow}`,
|
||||||
|
`='原始报文120条'!H${rawRow}`,
|
||||||
|
`='原始报文120条'!L${rawRow}`,
|
||||||
|
`='原始报文120条'!M${rawRow}`,
|
||||||
|
]];
|
||||||
|
const phase = index < 40 ? "充电前运行" : index < 60 ? "停车充电" : index < 80 ? "充电后纯电" : "充电后混动";
|
||||||
|
detail.getRange(`AC${row}`).values = [[phase]];
|
||||||
|
detail.getRange(`AD${row}`).formulas = [[`=IF(OR(C${row}<'原始报文120条'!$C$2+TIME(0,0,'参数与系数'!$B$7),AND(C${row}>='原始报文120条'!$C$62,C${row}<'原始报文120条'!$C$62+TIME(0,0,'参数与系数'!$B$7))),"否","是")`]];
|
||||||
|
detail.getRange(`AE${row}`).formulas = [[`=IF(OR(AND(C${row}>'原始报文120条'!$C$42-TIME(0,0,'参数与系数'!$B$8),C${row}<='原始报文120条'!$C$42),AND(C${row}>'原始报文120条'!$C$121-TIME(0,0,'参数与系数'!$B$8),C${row}<='原始报文120条'!$C$121)),"否","是")`]];
|
||||||
|
detail.getRange(`AF${row}`).formulas = [[`=IF(AND(W${row}=2,X${row}=1),"是","否")`]];
|
||||||
|
detail.getRange(`AG${row}`).formulas = [[`=IF(OR(AND(Y${row}=1,Z${row}=0),AND(Y${row}=2,Z${row}=1)),"是","否")`]];
|
||||||
|
detail.getRange(`AH${row}`).formulas = [[`=IF(AND(AD${row}="是",AE${row}="是",AF${row}="否",AG${row}="是",W${row}=1),"是","否")`]];
|
||||||
|
detail.getRange(`AI${row}`).formulas = [[`=IF(AH${row}="是","",IF(AF${row}="是","停车充电区间",IF(AD${row}="否","上电后60秒稳定窗口",IF(AE${row}="否","下电前60秒稳定窗口",IF(W${row}<>1,"车辆非运行状态","运行模式与燃料电池状态不匹配")))))`]];
|
||||||
|
detail.getRange(`AJ${row}`).formulas = [[`=IF(AH${row}<>"是","",IF(AND(Y${row}=1,Z${row}=0),"PURE_ELECTRIC",IF(AND(Y${row}=2,Z${row}=1),"MIXED","")))`]];
|
||||||
|
const segment = index >= 6 && index <= 34 ? 1 : index >= 66 && index <= 79 ? 2 : index >= 80 && index <= 113 ? 3 : null;
|
||||||
|
detail.getRange(`AK${row}`).values = [[segment]];
|
||||||
|
let endpointRole = "";
|
||||||
|
if (index >= 6 && index <= 10) endpointRole = `区间1起始候选${index - 5}`;
|
||||||
|
else if (index >= 30 && index <= 34) endpointRole = `区间1结束候选${index - 29}`;
|
||||||
|
else if (index >= 66 && index <= 70) endpointRole = `区间2起始候选${index - 65}`;
|
||||||
|
else if (index >= 75 && index <= 79) endpointRole = `区间2结束候选${index - 74}`;
|
||||||
|
else if (index >= 80 && index <= 84) endpointRole = `区间3起始候选${index - 79}`;
|
||||||
|
else if (index >= 109 && index <= 113) endpointRole = `区间3结束候选${index - 108}`;
|
||||||
|
detail.getRange(`AL${row}`).values = [[endpointRole]];
|
||||||
|
}
|
||||||
|
styleBody(detail.getRange(`A2:AL121`));
|
||||||
|
detail.getRange("C2:C121").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
|
||||||
|
detail.getRange("D2:V121").format.numberFormat = "0.000000";
|
||||||
|
detail.getRange("AA2:AB121").format.numberFormat = "0.000000";
|
||||||
|
detail.getRange("AH2:AH121").conditionalFormats.add("containsText", { text: "是", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
|
||||||
|
detail.getRange("AH2:AH121").conditionalFormats.add("containsText", { text: "否", format: { fill: colors.gray, font: { color: colors.grayText } } });
|
||||||
|
detail.getRange("AF2:AF121").conditionalFormats.add("containsText", { text: "是", format: { fill: colors.orange, font: { color: colors.orangeText, bold: true } } });
|
||||||
|
detail.getRange("AL2:AL121").conditionalFormats.add("notContainsBlanks", { format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
|
||||||
|
detail.tables.add("A1:AL121", true, "TelemetryCalculationDetail").style = "TableStyleMedium2";
|
||||||
|
detail.freezePanes.freezeRows(1);
|
||||||
|
detail.freezePanes.freezeColumns(3);
|
||||||
|
setWidths(detail, [8, 24, 20, 11, 11, 11, 12, ...Array(9).fill(13), 12, 14, 14, 16, 16, 16, 10, 10, 10, 12, 11, 16, 16, 13, 13, 12, 14, 12, 28, 16, 12, 18]);
|
||||||
|
|
||||||
|
// 每日与区间汇总,公式均追溯至逐条明细。
|
||||||
|
styleTitle(summary, summary.getRange("A1:AB1"), "120条原始报文:每日氢耗与区间计算汇总");
|
||||||
|
summary.getRange("A3:H3").values = [["统计日期", "原始样本数", "参与计算样本", "停车充电样本", "有效区间", "混动区间", "纯电区间", "质量状态"]];
|
||||||
|
styleHeader(summary.getRange("A3:H3"));
|
||||||
|
summary.getRange("A4").values = [["2026-08-26"]];
|
||||||
|
summary.getRange("B4:H4").formulas = [[
|
||||||
|
`=COUNTA('原始报文120条'!$B$2:$B$121)`,
|
||||||
|
`=COUNTIF('样本计算明细'!$AH$2:$AH$121,"是")`,
|
||||||
|
`=COUNTIF('样本计算明细'!$AF$2:$AF$121,"是")`,
|
||||||
|
`=COUNTA($A$9:$A$11)`,
|
||||||
|
`=COUNTIF($B$9:$B$11,"MIXED")`,
|
||||||
|
`=COUNTIF($B$9:$B$11,"PURE_ELECTRIC")`,
|
||||||
|
`=IF(AND(B4=120,C4>=10,F4>=1),"OK","SUSPECT")`,
|
||||||
|
]];
|
||||||
|
styleBody(summary.getRange("A4:H4"));
|
||||||
|
summary.getRange("H4").conditionalFormats.add("containsText", { text: "OK", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
|
||||||
|
|
||||||
|
summary.getRange("J3:Q3").values = [["物理耗氢(kg)", "SOC差值(百分点)", "电池净放电(kWh)", "电量折氢(kg)", "SOC平衡氢耗(kg)", "混动里程(km)", "物理百公里氢耗", "SOC平衡百公里氢耗"]];
|
||||||
|
styleHeader(summary.getRange("J3:Q3"));
|
||||||
|
summary.getRange("J4:Q4").formulas = [[
|
||||||
|
`=SUMIF($B$9:$B$11,"MIXED",$P$9:$P$11)`,
|
||||||
|
`=SUMIF($B$9:$B$11,"MIXED",$R$9:$R$11)-SUMIF($B$9:$B$11,"MIXED",$Q$9:$Q$11)`,
|
||||||
|
`=SUMIF($B$9:$B$11,"MIXED",$T$9:$T$11)`,
|
||||||
|
`=SUMIF($B$9:$B$11,"MIXED",$U$9:$U$11)`,
|
||||||
|
`=SUMIF($B$9:$B$11,"MIXED",$V$9:$V$11)`,
|
||||||
|
`=SUMIF($B$9:$B$11,"MIXED",$Y$9:$Y$11)`,
|
||||||
|
`=IF(O4>0,J4/O4*100,"")`,
|
||||||
|
`=IF(O4>0,N4/O4*100,"")`,
|
||||||
|
]];
|
||||||
|
styleBody(summary.getRange("J4:Q4"));
|
||||||
|
summary.getRange("J4:Q4").format.numberFormat = "0.000000";
|
||||||
|
|
||||||
|
summary.getRange("A7:AB7").values = [["区间", "类型", "有效样本", "起始候选行", "结束候选行", "起始报文ID", "结束报文ID", "起始时间", "结束时间", "起始压力", "结束压力", "起始温度", "结束温度", "起始质量", "结束质量", "物理耗氢", "起始SOC", "结束SOC", "SOC差值", "电池净放电", "电量折氢", "SOC平衡氢耗", "起始里程", "结束里程", "区间里程", "物理百公里", "SOC平衡百公里", "是否计入日氢耗"]];
|
||||||
|
styleHeader(summary.getRange("A7:AB7"));
|
||||||
|
const intervalSpecs = [
|
||||||
|
{ row: 9, index: 1, type: "MIXED", sampleCount: 29, startRows: [8, 12], endRows: [32, 36] },
|
||||||
|
{ row: 10, index: 2, type: "PURE_ELECTRIC", sampleCount: 14, startRows: [68, 72], endRows: [77, 81] },
|
||||||
|
{ row: 11, index: 3, type: "MIXED", sampleCount: 34, startRows: [82, 86], endRows: [111, 115] },
|
||||||
|
];
|
||||||
|
for (const spec of intervalSpecs) {
|
||||||
|
const r = spec.row;
|
||||||
|
const [ss, se] = spec.startRows;
|
||||||
|
const [es, ee] = spec.endRows;
|
||||||
|
summary.getRange(`A${r}:E${r}`).values = [[spec.index, spec.type, spec.sampleCount, `${ss}:${se}`, `${es}:${ee}`]];
|
||||||
|
summary.getRange(`F${r}`).formulas = [[`=INDEX('样本计算明细'!$B$${ss}:$B$${se},MATCH(MEDIAN('样本计算明细'!$T$${ss}:$T$${se}),'样本计算明细'!$T$${ss}:$T$${se},0))`]];
|
||||||
|
summary.getRange(`G${r}`).formulas = [[`=INDEX('样本计算明细'!$B$${es}:$B$${ee},MATCH(MEDIAN('样本计算明细'!$T$${es}:$T$${ee}),'样本计算明细'!$T$${es}:$T$${ee},0))`]];
|
||||||
|
summary.getRange(`H${r}`).formulas = [[`=INDEX('样本计算明细'!$C$${ss}:$C$${se},MATCH(MEDIAN('样本计算明细'!$T$${ss}:$T$${se}),'样本计算明细'!$T$${ss}:$T$${se},0))`]];
|
||||||
|
summary.getRange(`I${r}`).formulas = [[`=INDEX('样本计算明细'!$C$${es}:$C$${ee},MATCH(MEDIAN('样本计算明细'!$T$${es}:$T$${ee}),'样本计算明细'!$T$${es}:$T$${ee},0))`]];
|
||||||
|
const sourceCols = { J: "D", K: "D", L: "E", M: "E", N: "T", O: "T", Q: "AA", R: "AA", W: "AB", X: "AB" };
|
||||||
|
for (const [targetCol, sourceCol] of Object.entries(sourceCols)) {
|
||||||
|
const isStart = ["J", "L", "N", "Q", "W"].includes(targetCol);
|
||||||
|
const rs = isStart ? ss : es;
|
||||||
|
const re = isStart ? se : ee;
|
||||||
|
summary.getRange(`${targetCol}${r}`).formulas = [[`=INDEX('样本计算明细'!$${sourceCol}$${rs}:$${sourceCol}$${re},MATCH(MEDIAN('样本计算明细'!$T$${rs}:$T$${re}),'样本计算明细'!$T$${rs}:$T$${re},0))`]];
|
||||||
|
}
|
||||||
|
summary.getRange(`P${r}`).formulas = [[spec.type === "MIXED" ? `=MAX(0,N${r}-O${r})` : `=0`]];
|
||||||
|
summary.getRange(`S${r}`).formulas = [[`=R${r}-Q${r}`]];
|
||||||
|
summary.getRange(`T${r}`).formulas = [[spec.type === "MIXED" ? `='参数与系数'!$B$5*(Q${r}-R${r})/100` : `=""`]];
|
||||||
|
summary.getRange(`U${r}`).formulas = [[spec.type === "MIXED" ? `=T${r}/'参数与系数'!$B$6` : `=""`]];
|
||||||
|
summary.getRange(`V${r}`).formulas = [[spec.type === "MIXED" ? `=P${r}+U${r}` : `=""`]];
|
||||||
|
summary.getRange(`Y${r}`).formulas = [[`=MAX(0,X${r}-W${r})`]];
|
||||||
|
summary.getRange(`Z${r}`).formulas = [[spec.type === "MIXED" ? `=IF(Y${r}>0,P${r}/Y${r}*100,"")` : `=""`]];
|
||||||
|
summary.getRange(`AA${r}`).formulas = [[spec.type === "MIXED" ? `=IF(Y${r}>0,V${r}/Y${r}*100,"")` : `=""`]];
|
||||||
|
summary.getRange(`AB${r}`).values = [[spec.type === "MIXED" ? "是" : "否(仅计纯电里程)"]];
|
||||||
|
}
|
||||||
|
styleBody(summary.getRange("A9:AB11"));
|
||||||
|
summary.getRange("H9:I11").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
|
||||||
|
summary.getRange("J9:AA11").format.numberFormat = "0.000000";
|
||||||
|
summary.getRange("B9:B11").conditionalFormats.add("containsText", { text: "MIXED", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
|
||||||
|
summary.getRange("B9:B11").conditionalFormats.add("containsText", { text: "PURE_ELECTRIC", format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
|
||||||
|
|
||||||
|
summary.getRange("A14:AB18").values = [
|
||||||
|
["人工核验顺序", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["1. 在“原始报文120条”核对每条车辆状态、充电状态、运行模式、压力、温度、SOC和仪表里程。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["2. 在“样本计算明细”逐项复核Tₖ、9个Z分项、压缩因子Z、分子、分母、质量及参与计算原因。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["3. 在本页核对每个区间首尾5条候选样本的中位质量、对应原始报文ID、物理耗氢、SOC修正和里程。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
["4. 停车充电区间不计氢耗;充电结束后重新建立基线。纯电区间只累计纯电里程,不计入混动氢耗分母。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
|
||||||
|
];
|
||||||
|
for (let row = 14; row <= 18; row++) summary.getRange(`A${row}:AB${row}`).merge();
|
||||||
|
summary.getRange("A14:AB14").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
|
||||||
|
summary.getRange("A15:AB18").format = { fill: colors.pale, font: { color: colors.grayText }, wrapText: true };
|
||||||
|
summary.freezePanes.freezeRows(7);
|
||||||
|
summary.freezePanes.freezeColumns(2);
|
||||||
|
setWidths(summary, [8, 18, 12, 14, 14, 24, 24, 20, 20, 13, 13, 13, 13, 15, 15, 15, 13, 13, 13, 16, 14, 17, 15, 15, 14, 15, 17, 20]);
|
||||||
|
|
||||||
|
// 说明首页,关键结果均跨表引用。
|
||||||
|
styleTitle(guide, guide.getRange("A1:H1"), "氢耗算法120组人工验算底稿");
|
||||||
|
guide.getRange("A3:H4").merge();
|
||||||
|
guide.getRange("A3").values = [["用途:供人工使用原始字段逐步复核压力—质量换算、充电剔除、充电后重建基线、纯电/混动分段、SOC能量修正及最终日氢耗。样本为验算构造数据,不代表生产车辆真实报文。"]];
|
||||||
|
guide.getRange("A3:H4").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true, verticalAlignment: "center" };
|
||||||
|
guide.getRange("A6:H6").values = [["核验项目", "数量/结果", "口径", "核验项目", "数量/结果", "口径", "状态", "备注"]];
|
||||||
|
styleHeader(guide.getRange("A6:H6"));
|
||||||
|
guide.getRange("A7:H10").values = [
|
||||||
|
["NIST压力质量样本", null, "12压力×10温度", "原始报文样本", null, "含停车充电和充电后重基线", null, ""],
|
||||||
|
["NIST通过数", null, "系统值与Excel公式差≤1e-10", "参与计算样本", null, "上/下电窗口及充电剔除后", null, ""],
|
||||||
|
["物理耗氢", null, "仅有效混动区间", "SOC平衡氢耗", null, "物理耗氢+电池净放电折氢", null, ""],
|
||||||
|
["混动里程", null, "仅混动区间", "纯电里程", null, "只统计,不进入混动氢耗分母", null, ""],
|
||||||
|
];
|
||||||
|
guide.getRange("B7").formulas = [[`=COUNTA('120组压力质量验算'!$A$2:$A$121)`]];
|
||||||
|
guide.getRange("E7").formulas = [[`=COUNTA('原始报文120条'!$A$2:$A$121)`]];
|
||||||
|
guide.getRange("G7").formulas = [[`=IF(AND(B7=120,E7=120),"完整","不完整")`]];
|
||||||
|
guide.getRange("B8").formulas = [[`=COUNTIF('120组压力质量验算'!$U$2:$U$121,"通过")`]];
|
||||||
|
guide.getRange("E8").formulas = [[`='每日与区间汇总'!$C$4`]];
|
||||||
|
guide.getRange("G8").formulas = [[`=IF(B8=120,"通过","不通过")`]];
|
||||||
|
guide.getRange("B9").formulas = [[`='每日与区间汇总'!$J$4`]];
|
||||||
|
guide.getRange("E9").formulas = [[`='每日与区间汇总'!$N$4`]];
|
||||||
|
guide.getRange("G9").formulas = [[`='每日与区间汇总'!$H$4`]];
|
||||||
|
guide.getRange("B10").formulas = [[`='每日与区间汇总'!$O$4`]];
|
||||||
|
guide.getRange("E10").formulas = [[`=SUMIF('每日与区间汇总'!$B$9:$B$11,"PURE_ELECTRIC",'每日与区间汇总'!$Y$9:$Y$11)`]];
|
||||||
|
guide.getRange("G10").values = [["可追溯"]];
|
||||||
|
styleBody(guide.getRange("A7:H10"));
|
||||||
|
guide.getRange("B9:B10").format.numberFormat = "0.000000";
|
||||||
|
guide.getRange("E9:E10").format.numberFormat = "0.000000";
|
||||||
|
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "通过", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
|
||||||
|
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "完整", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
|
||||||
|
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "可追溯", format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
|
||||||
|
|
||||||
|
guide.getRange("A13:H13").values = [["工作表", "核验内容", "人工操作", "", "", "", "", ""]];
|
||||||
|
styleHeader(guide.getRange("A13:H13"));
|
||||||
|
const guideRows = [
|
||||||
|
["参数与系数", "所有常量和9组NIST系数", "先确认车型参数,再复核公式引用"],
|
||||||
|
["120组压力质量验算", "120组压力/温度的完整中间值和最终差异", "逐行查看Z分项→Z→分子/分母→质量→通过结果"],
|
||||||
|
["原始报文120条", "车辆状态、充电状态、运行模式、压力、温度、SOC、里程及JSON", "核对原始字段,不修改计算列"],
|
||||||
|
["样本计算明细", "每条报文换算质量、边界过滤、充电识别和区间归属", "按报文ID追踪排除原因及端点候选"],
|
||||||
|
["每日与区间汇总", "端点中位数、分段耗氢、SOC修正、里程和百公里氢耗", "从最终结果反查起止报文ID"],
|
||||||
|
];
|
||||||
|
guide.getRange("A14:C18").values = guideRows;
|
||||||
|
for (let row = 14; row <= 18; row++) guide.getRange(`C${row}:H${row}`).merge();
|
||||||
|
styleBody(guide.getRange("A14:H18"));
|
||||||
|
guide.getRange("A21:H21").merge();
|
||||||
|
guide.getRange("A21").values = [["关键规则:停车充电 = 车辆状态为熄火(2) 且充电状态为停车充电(1)。充电期间不计算氢耗;充电结束后重新建立SOC、压力、温度和里程基线,仅对运行区间SOC变化进行氢气等效修正。"]];
|
||||||
|
guide.getRange("A21:H21").format = { fill: colors.orange, font: { bold: true, color: colors.orangeText }, wrapText: true };
|
||||||
|
guide.getRange("A21:H21").format.rowHeight = 48;
|
||||||
|
setWidths(guide, [24, 18, 34, 24, 18, 34, 14, 28]);
|
||||||
|
|
||||||
|
await fs.mkdir(previewDir, { recursive: true });
|
||||||
|
|
||||||
|
const checks = {};
|
||||||
|
for (const [name, range] of [
|
||||||
|
["guide", "验算说明!A1:H21"],
|
||||||
|
["parameters", "参数与系数!A1:F33"],
|
||||||
|
["grid", "120组压力质量验算!A1:U12"],
|
||||||
|
["raw", "原始报文120条!A1:N15"],
|
||||||
|
["detail", "样本计算明细!A1:AL15"],
|
||||||
|
["summary", "每日与区间汇总!A1:AB18"],
|
||||||
|
]) {
|
||||||
|
const [sheetName, a1] = range.split("!");
|
||||||
|
const inspection = await workbook.inspect({ kind: "table", sheetId: sheetName, range: a1, include: "values,formulas", tableMaxRows: 20, tableMaxCols: 40, maxChars: 12000 });
|
||||||
|
checks[name] = inspection.ndjson;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formulaErrors = await workbook.inspect({
|
||||||
|
kind: "match",
|
||||||
|
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
|
||||||
|
options: { useRegex: true, maxResults: 300 },
|
||||||
|
summary: "final formula error scan",
|
||||||
|
maxChars: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sheetName of ["验算说明", "参数与系数", "120组压力质量验算", "原始报文120条", "样本计算明细", "每日与区间汇总"]) {
|
||||||
|
const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 0.8, format: "png" });
|
||||||
|
await fs.writeFile(`${previewDir}/${sheetName}.png`, new Uint8Array(await preview.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const xlsx = await SpreadsheetFile.exportXlsx(workbook);
|
||||||
|
await xlsx.save(outputPath);
|
||||||
|
await fs.writeFile(`${outputDir}/verification.json`, JSON.stringify({ outputPath, checks, formulaErrors: formulaErrors.ndjson }, null, 2));
|
||||||
|
console.log(JSON.stringify({ outputPath, previewDir, formulaErrors: formulaErrors.ndjson }));
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const evidencePath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/evidence.json";
|
||||||
|
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083";
|
||||||
|
const outputPath = path.join(outputDir, "15辆车里程核验申诉材料_GB32960日里程及原始报文.xlsx");
|
||||||
|
const previewDir = path.join(outputDir, "preview");
|
||||||
|
const data = JSON.parse(await fs.readFile(evidencePath, "utf8"));
|
||||||
|
|
||||||
|
const workbook = Workbook.create();
|
||||||
|
const summary = workbook.worksheets.add("申诉总览");
|
||||||
|
const notes = workbook.worksheets.add("口径与证据说明");
|
||||||
|
const vehicleSheets = new Map();
|
||||||
|
for (const vehicle of data.vehicles) {
|
||||||
|
const sheetName = `${String(vehicle.seq).padStart(2, "0")}-${vehicle.plate}`;
|
||||||
|
vehicleSheets.set(vehicle.vin, { sheetName, sheet: workbook.worksheets.add(sheetName) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
navy: "#1F4E78",
|
||||||
|
blue: "#D9EAF7",
|
||||||
|
blue2: "#EAF3F8",
|
||||||
|
yellow: "#FFF2CC",
|
||||||
|
amber: "#FCE4D6",
|
||||||
|
mileageHeader: "#C65911",
|
||||||
|
mileageLight: "#FFF2CC",
|
||||||
|
mileageStrong: "#FFD966",
|
||||||
|
green: "#E2F0D9",
|
||||||
|
gray: "#E7E6E6",
|
||||||
|
dark: "#1F2937",
|
||||||
|
muted: "#5B6573",
|
||||||
|
border: "#B7C9D6",
|
||||||
|
white: "#FFFFFF",
|
||||||
|
};
|
||||||
|
|
||||||
|
const thinBorders = { preset: "all", style: "thin", color: palette.border };
|
||||||
|
const titleFormat = {
|
||||||
|
fill: palette.navy,
|
||||||
|
font: { bold: true, color: palette.white, fontSize: 16, typeface: "Microsoft YaHei" },
|
||||||
|
horizontalAlignment: "left",
|
||||||
|
verticalAlignment: "center",
|
||||||
|
};
|
||||||
|
const sectionFormat = {
|
||||||
|
fill: palette.blue,
|
||||||
|
font: { bold: true, color: palette.dark, fontSize: 11, typeface: "Microsoft YaHei" },
|
||||||
|
horizontalAlignment: "left",
|
||||||
|
verticalAlignment: "center",
|
||||||
|
borders: thinBorders,
|
||||||
|
};
|
||||||
|
const headerFormat = {
|
||||||
|
fill: palette.navy,
|
||||||
|
font: { bold: true, color: palette.white, fontSize: 9, typeface: "Microsoft YaHei" },
|
||||||
|
horizontalAlignment: "center",
|
||||||
|
verticalAlignment: "center",
|
||||||
|
wrapText: true,
|
||||||
|
borders: thinBorders,
|
||||||
|
};
|
||||||
|
const labelFormat = {
|
||||||
|
fill: palette.blue2,
|
||||||
|
font: { bold: true, color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" },
|
||||||
|
horizontalAlignment: "left",
|
||||||
|
verticalAlignment: "center",
|
||||||
|
wrapText: true,
|
||||||
|
borders: thinBorders,
|
||||||
|
};
|
||||||
|
const valueFormat = {
|
||||||
|
font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" },
|
||||||
|
verticalAlignment: "center",
|
||||||
|
wrapText: true,
|
||||||
|
borders: thinBorders,
|
||||||
|
};
|
||||||
|
|
||||||
|
function asDate(day) {
|
||||||
|
return new Date(`${day}T00:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(day, count) {
|
||||||
|
const date = asDate(day);
|
||||||
|
date.setUTCDate(date.getUTCDate() + count);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDays(start, end) {
|
||||||
|
const days = [];
|
||||||
|
for (let day = start; day <= end; day = addDays(day, 1)) days.push(day);
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absDelta(a, b) {
|
||||||
|
if (a == null || b == null || Number.isNaN(Number(a)) || Number.isNaN(Number(b))) return null;
|
||||||
|
return Math.abs(Number(a) - Number(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawRange(vehicle) {
|
||||||
|
const rows = vehicle.rawEvidence.filter((row) => row.status === "OK");
|
||||||
|
if (rows.length === 0) return "无";
|
||||||
|
return `${rows[0].day} 至 ${rows.at(-1).day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceLevel(metric, raw) {
|
||||||
|
if (raw?.status === "OK") return "A-原始32960报文";
|
||||||
|
if (metric?.source_code === "legacy_lingniu_prod") return "C-历史日统计迁移(无RAW)";
|
||||||
|
if (metric) return "B-中台32960日统计(无RAW)";
|
||||||
|
return "D-无中台日记录";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowNote(metric, raw) {
|
||||||
|
const notes = [];
|
||||||
|
if (metric?.source_code === "legacy_lingniu_prod") notes.push("历史生产库 ln_vehicle_day_mileage 迁移记录,不是原始帧");
|
||||||
|
if (raw?.status === "OK" && !metric) notes.push("有原始报文,但中台日统计无记录");
|
||||||
|
if (raw?.status === "OK" && metric && absDelta(metric.latest_total_mileage_km, raw.parsedTotalMileageKm) > 0.1) {
|
||||||
|
notes.push("日统计日末值与本日最后可得RAW读数不一致,详见数据源/时间;可能涉及人工源选举或跨日延迟");
|
||||||
|
}
|
||||||
|
if (raw?.status !== "OK" && metric && metric.source_code !== "legacy_lingniu_prod") notes.push("有中台日统计,但当日未检出含仪表总里程的RAW帧");
|
||||||
|
if (!metric && raw?.status !== "OK") notes.push("当日无中台日记录,且现行RAW历史不可用或未检出");
|
||||||
|
return notes.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStandardSheet(sheet) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
sheet.freezePanes.freezeRows(13);
|
||||||
|
sheet.freezePanes.freezeColumns(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vehicle of data.vehicles) {
|
||||||
|
const { sheetName, sheet } = vehicleSheets.get(vehicle.vin);
|
||||||
|
setStandardSheet(sheet);
|
||||||
|
sheet.getRange("A1:P1").merge();
|
||||||
|
sheet.getRange("A1").values = [[`${vehicle.plate} 里程核验申诉明细`]];
|
||||||
|
sheet.getRange("A1:P1").format = titleFormat;
|
||||||
|
sheet.getRange("A1:P1").format.rowHeight = 30;
|
||||||
|
|
||||||
|
sheet.getRange("A2:P2").merge();
|
||||||
|
sheet.getRange("A2").values = [["核验口径:GB/T 32960 车辆仪表累计总里程;原始报文列保存完整HEX,点击单元格可在公式栏复制。相邻自然日均有RAW读数时才计算RAW日差。"]];
|
||||||
|
sheet.getRange("A2:P2").format = { fill: palette.yellow, font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, wrapText: true, verticalAlignment: "center", borders: thinBorders };
|
||||||
|
sheet.getRange("A2:P2").format.rowHeight = 30;
|
||||||
|
|
||||||
|
sheet.getRange("A4:P6").format = valueFormat;
|
||||||
|
sheet.getRange("A4").values = [["车牌"]];
|
||||||
|
sheet.getRange("B4:C4").merge(); sheet.getRange("B4").values = [[vehicle.plate]];
|
||||||
|
sheet.getRange("D4").values = [["VIN"]];
|
||||||
|
sheet.getRange("E4:H4").merge(); sheet.getRange("E4").values = [[vehicle.vin]];
|
||||||
|
sheet.getRange("I4").values = [["车型"]];
|
||||||
|
sheet.getRange("J4:K4").merge(); sheet.getRange("J4").values = [[vehicle.model]];
|
||||||
|
sheet.getRange("L4").values = [["协议"]];
|
||||||
|
sheet.getRange("M4:P4").merge(); sheet.getRange("M4").values = [["GB32960(仪表累计总里程)"]];
|
||||||
|
|
||||||
|
sheet.getRange("A5").values = [["核验区间"]];
|
||||||
|
sheet.getRange("B5:C5").merge(); sheet.getRange("B5").values = [["2025-11-28 至 2026-07-31"]];
|
||||||
|
sheet.getRange("D5").values = [["原TBOX合计(km)"]];
|
||||||
|
sheet.getRange("E5:F5").merge(); sheet.getRange("E5").values = [[vehicle.sourceTboxKm]];
|
||||||
|
sheet.getRange("G5").values = [["第三方平台合计(km)"]];
|
||||||
|
sheet.getRange("H5:I5").merge(); sheet.getRange("H5").values = [[vehicle.platformKm]];
|
||||||
|
sheet.getRange("J5").values = [["原表损耗(km)"]];
|
||||||
|
sheet.getRange("K5:L5").merge(); sheet.getRange("K5").values = [[vehicle.lossKm]];
|
||||||
|
sheet.getRange("M5").values = [["原申诉里程(km)"]];
|
||||||
|
sheet.getRange("N5:P5").merge(); sheet.getRange("N5").values = [[vehicle.appealKm]];
|
||||||
|
sheet.getRange("E5:F5").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("H5:I5").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("K5:L5").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("N5:P5").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("K5:L5").format.fill = palette.yellow;
|
||||||
|
|
||||||
|
sheet.getRange("A6").values = [["异常标记"]];
|
||||||
|
sheet.getRange("B6:D6").merge(); sheet.getRange("B6").values = [[vehicle.anomaly]];
|
||||||
|
sheet.getRange("E6").values = [["原排查结论"]];
|
||||||
|
sheet.getRange("F6:P6").merge(); sheet.getRange("F6").values = [[vehicle.note]];
|
||||||
|
for (const address of ["A4", "A5", "A6", "D4", "D5", "E6", "G5", "I4", "J5", "L4", "M5"]) sheet.getRange(address).format = labelFormat;
|
||||||
|
|
||||||
|
sheet.getRange("A8:P8").format = valueFormat;
|
||||||
|
sheet.getRange("A8").values = [["原车辆统计合计(km)"]];
|
||||||
|
sheet.getRange("B8:C8").merge(); sheet.getRange("B8").values = [[vehicle.sourceTboxKm]];
|
||||||
|
sheet.getRange("D8").values = [["车辆数据中台统计合计(km)"]];
|
||||||
|
sheet.getRange("E8:F8").merge(); sheet.getRange("E8").formulas = [["=SUM(B14:B259)"]];
|
||||||
|
sheet.getRange("G8").values = [["中台日统计有值天数"]];
|
||||||
|
sheet.getRange("H8:I8").merge(); sheet.getRange("H8").formulas = [["=COUNT(B14:B259)"]];
|
||||||
|
sheet.getRange("J8").values = [["RAW仪表证据天数"]];
|
||||||
|
sheet.getRange("K8:L8").merge(); sheet.getRange("K8").formulas = [["=COUNT(F14:F259)"]];
|
||||||
|
sheet.getRange("M8").values = [["RAW证据日期范围"]];
|
||||||
|
sheet.getRange("N8:P8").merge(); sheet.getRange("N8").values = [[rawRange(vehicle)]];
|
||||||
|
for (const address of ["A8", "D8", "G8", "J8", "M8"]) sheet.getRange(address).format = labelFormat;
|
||||||
|
sheet.getRange("B8:C8").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("E8:F8").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("B8:C8").format.fill = palette.blue2;
|
||||||
|
sheet.getRange("E8:F8").format.fill = palette.mileageStrong;
|
||||||
|
sheet.getRange("E8:F8").format.font = { bold: true, color: "#7F6000", fontSize: 10, typeface: "Microsoft YaHei" };
|
||||||
|
sheet.getRange("A8:P8").format.rowHeight = 38;
|
||||||
|
|
||||||
|
sheet.getRange("A9").values = [["证据限制"]];
|
||||||
|
sheet.getRange("B9:P9").merge();
|
||||||
|
sheet.getRange("B9").values = [["现行TDengine RAW历史自2026-07-01起可查询;此前仅有历史生产库日统计迁移记录。材料已按A/B/C/D分级,未将统计记录伪称为原始报文。"]];
|
||||||
|
sheet.getRange("A9").format = labelFormat;
|
||||||
|
sheet.getRange("B9:P9").format = { ...valueFormat, fill: palette.amber };
|
||||||
|
sheet.getRange("A9:P9").format.rowHeight = 30;
|
||||||
|
|
||||||
|
sheet.getRange("A11:P11").merge();
|
||||||
|
sheet.getRange("A11").values = [["每日核验明细(完整日历)"]];
|
||||||
|
sheet.getRange("A11:P11").format = sectionFormat;
|
||||||
|
|
||||||
|
sheet.getRange("A12:D12").merge();
|
||||||
|
sheet.getRange("A12").values = [["车辆数据中台区间里程总计"]];
|
||||||
|
sheet.getRange("E12:F12").merge();
|
||||||
|
sheet.getRange("E12").formulas = [["=SUM(B14:B259)"]];
|
||||||
|
sheet.getRange("G12:P12").merge();
|
||||||
|
sheet.getRange("G12").values = [["计算范围:B14:B259(下方“中台当日里程(km)”列)"]];
|
||||||
|
sheet.getRange("A12:D12").format = {
|
||||||
|
...headerFormat,
|
||||||
|
fill: palette.mileageHeader,
|
||||||
|
horizontalAlignment: "left",
|
||||||
|
};
|
||||||
|
sheet.getRange("E12:F12").format = {
|
||||||
|
...valueFormat,
|
||||||
|
fill: palette.mileageStrong,
|
||||||
|
font: { bold: true, color: "#7F6000", fontSize: 11, typeface: "Microsoft YaHei" },
|
||||||
|
numberFormat: "#,##0.0",
|
||||||
|
horizontalAlignment: "right",
|
||||||
|
};
|
||||||
|
sheet.getRange("G12:P12").format = {
|
||||||
|
...valueFormat,
|
||||||
|
fill: palette.mileageLight,
|
||||||
|
font: { color: palette.textMuted, fontSize: 9, typeface: "Microsoft YaHei" },
|
||||||
|
};
|
||||||
|
sheet.getRange("A12:P12").format.rowHeight = 30;
|
||||||
|
|
||||||
|
const headers = ["日期", "中台当日里程(km)", "中台日末仪表总里程(km)", "日统计数据源", "日统计来源端点", "RAW解析仪表总里程(km)", "RAW相邻日差(km)", "日统计-RAW差(km)", "证据等级", "报文事件时间", "中台接收时间", "RAW来源端点", "Event ID", "Frame ID", "原始报文大小(bytes)", "原始GB32960报文HEX(完整)"];
|
||||||
|
sheet.getRange("A13:P13").values = [headers];
|
||||||
|
sheet.getRange("A13:P13").format = headerFormat;
|
||||||
|
sheet.getRange("A13:P13").format.rowHeight = 42;
|
||||||
|
|
||||||
|
const metricByDay = new Map(vehicle.dailyMetrics.map((row) => [row.stat_date, row]));
|
||||||
|
const rawByDay = new Map(vehicle.rawEvidence.map((row) => [row.day, row]));
|
||||||
|
const days = calendarDays(data.period.start, data.period.end);
|
||||||
|
const rows = days.map((day) => {
|
||||||
|
const metric = metricByDay.get(day);
|
||||||
|
const raw = rawByDay.get(day);
|
||||||
|
return [
|
||||||
|
asDate(day),
|
||||||
|
metric?.daily_mileage_km ?? null,
|
||||||
|
metric?.latest_total_mileage_km ?? null,
|
||||||
|
metric ? `${metric.platform_name || ""} / ${metric.source_code || ""}` : "",
|
||||||
|
metric?.latest_source_endpoint ?? "",
|
||||||
|
raw?.status === "OK" ? raw.parsedTotalMileageKm : null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
evidenceLevel(metric, raw),
|
||||||
|
raw?.status === "OK" ? raw.eventTime : "",
|
||||||
|
raw?.status === "OK" ? raw.receivedAt : "",
|
||||||
|
raw?.status === "OK" ? raw.sourceEndpoint : "",
|
||||||
|
raw?.status === "OK" ? raw.eventId : "",
|
||||||
|
raw?.status === "OK" ? raw.frameId : "",
|
||||||
|
raw?.status === "OK" ? raw.rawSizeBytes : null,
|
||||||
|
raw?.status === "OK" ? raw.rawHex : "",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
sheet.getRange("A14:P259").values = rows;
|
||||||
|
sheet.getRange("A14:A259").format.numberFormat = "yyyy-mm-dd";
|
||||||
|
sheet.getRange("B14:C259").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("F14:H259").format.numberFormat = "#,##0.0";
|
||||||
|
sheet.getRange("O14:O259").format.numberFormat = "#,##0";
|
||||||
|
sheet.getRange("A14:P259").format = {
|
||||||
|
font: { color: palette.dark, fontSize: 8, typeface: "Microsoft YaHei" },
|
||||||
|
verticalAlignment: "center",
|
||||||
|
borders: { insideHorizontal: { style: "thin", color: "#D9E2E8" }, bottom: { style: "thin", color: palette.border } },
|
||||||
|
};
|
||||||
|
sheet.getRange("A14:A259").format.horizontalAlignment = "center";
|
||||||
|
sheet.getRange("B14:C259").format.horizontalAlignment = "right";
|
||||||
|
sheet.getRange("F14:H259").format.horizontalAlignment = "right";
|
||||||
|
sheet.getRange("I14:O259").format.wrapText = true;
|
||||||
|
sheet.getRange("P14:P259").format = { font: { color: palette.muted, fontSize: 7, typeface: "Consolas" }, wrapText: true, verticalAlignment: "top" };
|
||||||
|
sheet.getRange("A14:P259").format.rowHeight = 34;
|
||||||
|
sheet.getRange("G14").values = [[null]];
|
||||||
|
for (let row = 15; row <= 259; row += 1) {
|
||||||
|
sheet.getRange(`G${row}`).formulas = [[`=IF(OR(F${row}="",F${row - 1}=""),"",F${row}-F${row - 1})`]];
|
||||||
|
}
|
||||||
|
for (let row = 14; row <= 259; row += 1) {
|
||||||
|
sheet.getRange(`H${row}`).formulas = [[`=IF(OR(C${row}="",F${row}=""),"",C${row}-F${row})`]];
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "A-", format: { fill: palette.green, font: { color: "#375623", bold: true } } });
|
||||||
|
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "C-", format: { fill: palette.yellow, font: { color: "#7F6000" } } });
|
||||||
|
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "D-", format: { fill: palette.gray, font: { color: palette.muted } } });
|
||||||
|
sheet.getRange("H14:H259").conditionalFormats.addCustom("=ABS($H14)>0.1", { fill: palette.amber, font: { color: "#9C0006", bold: true } });
|
||||||
|
|
||||||
|
const table = sheet.tables.add("A13:P259", true, `Vehicle${String(vehicle.seq).padStart(2, "0")}Evidence`);
|
||||||
|
table.style = "TableStyleMedium2";
|
||||||
|
table.showBandedColumns = false;
|
||||||
|
table.showFilterButton = true;
|
||||||
|
sheet.getRange("B13").format = { ...headerFormat, fill: palette.mileageHeader };
|
||||||
|
sheet.getRange("B14:B259").format.fill = palette.mileageLight;
|
||||||
|
sheet.getRange("B14:B259").conditionalFormats.add("cellIs", {
|
||||||
|
operator: "greaterThan",
|
||||||
|
formula: 0,
|
||||||
|
format: { fill: palette.mileageStrong, font: { bold: true, color: "#7F6000" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const widths = [12, 17, 20, 24, 26, 20, 17, 18, 25, 20, 20, 22, 34, 34, 14, 72];
|
||||||
|
for (let col = 0; col < widths.length; col += 1) sheet.getRangeByIndexes(0, col, 259, 1).format.columnWidth = widths[col];
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.showGridLines = false;
|
||||||
|
summary.freezePanes.freezeRows(4);
|
||||||
|
summary.getRange("A1:P1").merge();
|
||||||
|
summary.getRange("A1").values = [["15辆车里程核验申诉总览"]];
|
||||||
|
summary.getRange("A1:P1").format = titleFormat;
|
||||||
|
summary.getRange("A1:P1").format.rowHeight = 32;
|
||||||
|
summary.getRange("A2:P2").merge();
|
||||||
|
summary.getRange("A2").values = [["结论:车辆数据中台GB32960日里程复核合计与原异常表TBOX区间里程逐车一致;第三方平台合计显著偏低。RAW原始帧按现有可查历史提供,早期记录已明确标注证据限制。"]];
|
||||||
|
summary.getRange("A2:P2").format = { fill: palette.green, font: { bold: true, color: "#375623", fontSize: 10, typeface: "Microsoft YaHei" }, wrapText: true, verticalAlignment: "center", borders: thinBorders };
|
||||||
|
summary.getRange("A2:P2").format.rowHeight = 34;
|
||||||
|
|
||||||
|
summary.getRange("A3:B3").merge(); summary.getRange("A3").values = [["全部15辆车·原车辆统计(km)"]];
|
||||||
|
summary.getRange("C3").formulas = [["=F20"]];
|
||||||
|
summary.getRange("D3:E3").merge(); summary.getRange("D3").values = [["车辆数据中台统计(km)"]];
|
||||||
|
summary.getRange("F3").formulas = [["=G20"]];
|
||||||
|
summary.getRange("G3:H3").merge(); summary.getRange("G3").values = [["第三方平台统计(km)"]];
|
||||||
|
summary.getRange("I3").formulas = [["=H20"]];
|
||||||
|
summary.getRange("J3:K3").merge(); summary.getRange("J3").values = [["平台漏计差异(km)"]];
|
||||||
|
summary.getRange("L3").formulas = [["=I20"]];
|
||||||
|
summary.getRange("M3:N3").merge(); summary.getRange("M3").values = [["RAW仪表证据(天)"]];
|
||||||
|
summary.getRange("O3:P3").merge(); summary.getRange("O3").formulas = [["=M20"]];
|
||||||
|
for (const address of ["A3:B3", "D3:E3", "G3:H3", "J3:K3", "M3:N3"]) summary.getRange(address).format = labelFormat;
|
||||||
|
for (const address of ["C3", "F3", "I3", "L3", "O3:P3"]) summary.getRange(address).format = { ...valueFormat, fill: palette.mileageLight, font: { bold: true, color: "#7F6000", fontSize: 10, typeface: "Microsoft YaHei" }, horizontalAlignment: "right" };
|
||||||
|
summary.getRange("C3:L3").format.numberFormat = "#,##0.0";
|
||||||
|
summary.getRange("O3:P3").format.numberFormat = "#,##0";
|
||||||
|
summary.getRange("A3:P3").format.rowHeight = 34;
|
||||||
|
|
||||||
|
const summaryHeaders = ["序号", "车型", "车牌", "VIN", "核验区间", "原TBOX合计(km)", "中台复核合计(km)", "第三方平台合计(km)", "损耗(km)", "损耗率", "申诉里程(km)", "日统计天数", "RAW证据天数", "RAW证据范围", "核验结论", "原排查结论"];
|
||||||
|
summary.getRange("A4:P4").values = [summaryHeaders];
|
||||||
|
summary.getRange("A4:P4").format = headerFormat;
|
||||||
|
summary.getRange("A4:P4").format.rowHeight = 40;
|
||||||
|
const summaryRows = data.vehicles.map((vehicle) => [
|
||||||
|
vehicle.seq, vehicle.model, vehicle.plate, vehicle.vin, "2025-11-28 至 2026-07-31", vehicle.sourceTboxKm, null, vehicle.platformKm, null, null, vehicle.appealKm, null, null, rawRange(vehicle), null, vehicle.note,
|
||||||
|
]);
|
||||||
|
summary.getRange("A5:P19").values = summaryRows;
|
||||||
|
for (let index = 0; index < data.vehicles.length; index += 1) {
|
||||||
|
const row = 5 + index;
|
||||||
|
const { sheetName } = vehicleSheets.get(data.vehicles[index].vin);
|
||||||
|
summary.getRange(`G${row}`).formulas = [[`='${sheetName}'!E8`]];
|
||||||
|
summary.getRange(`I${row}`).formulas = [[`=F${row}-H${row}`]];
|
||||||
|
summary.getRange(`J${row}`).formulas = [[`=IF(F${row}=0,"",I${row}/F${row})`]];
|
||||||
|
summary.getRange(`L${row}`).formulas = [[`='${sheetName}'!H8`]];
|
||||||
|
summary.getRange(`M${row}`).formulas = [[`='${sheetName}'!K8`]];
|
||||||
|
summary.getRange(`O${row}`).formulas = [[`=IF(ABS(G${row}-F${row})<0.1,"中台复核合计与原TBOX一致","需复核中台合计")`]];
|
||||||
|
}
|
||||||
|
summary.getRange("A20:E20").merge(); summary.getRange("A20").values = [["合计"]];
|
||||||
|
summary.getRange("F20").formulas = [["=SUM(F5:F19)"]];
|
||||||
|
summary.getRange("G20").formulas = [["=SUM(G5:G19)"]];
|
||||||
|
summary.getRange("H20").formulas = [["=SUM(H5:H19)"]];
|
||||||
|
summary.getRange("I20").formulas = [["=SUM(I5:I19)"]];
|
||||||
|
summary.getRange("J20").formulas = [["=IF(F20=0,\"\",I20/F20)"]];
|
||||||
|
summary.getRange("K20").formulas = [["=SUM(K5:K19)"]];
|
||||||
|
summary.getRange("L20").formulas = [["=SUM(L5:L19)"]];
|
||||||
|
summary.getRange("M20").formulas = [["=SUM(M5:M19)"]];
|
||||||
|
summary.getRange("N20:P20").merge(); summary.getRange("N20").values = [["15车合计;RAW证据为现行历史可查询范围"]];
|
||||||
|
summary.getRange("A20:P20").format = { fill: palette.blue, font: { bold: true, color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, borders: thinBorders, verticalAlignment: "center", wrapText: true };
|
||||||
|
summary.getRange("A5:P19").format = { font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, verticalAlignment: "center", wrapText: true, borders: { insideHorizontal: { style: "thin", color: "#D9E2E8" }, bottom: { style: "thin", color: palette.border } } };
|
||||||
|
summary.getRange("F5:I20").format.numberFormat = "#,##0.0";
|
||||||
|
summary.getRange("J5:J20").format.numberFormat = "0.00%";
|
||||||
|
summary.getRange("K5:K20").format.numberFormat = "#,##0.0";
|
||||||
|
summary.getRange("L5:M20").format.numberFormat = "#,##0";
|
||||||
|
summary.getRange("I5:I19").format.fill = palette.yellow;
|
||||||
|
summary.getRange("O5:O19").conditionalFormats.add("containsText", { text: "一致", format: { fill: palette.green, font: { color: "#375623", bold: true } } });
|
||||||
|
const summaryTable = summary.tables.add("A4:P19", true, "AppealSummary");
|
||||||
|
summaryTable.style = "TableStyleMedium2";
|
||||||
|
summaryTable.showFilterButton = true;
|
||||||
|
const summaryWidths = [8, 9, 14, 22, 22, 16, 18, 18, 15, 12, 16, 13, 14, 24, 26, 54];
|
||||||
|
for (let col = 0; col < summaryWidths.length; col += 1) summary.getRangeByIndexes(0, col, 20, 1).format.columnWidth = summaryWidths[col];
|
||||||
|
summary.getRange("A5:P19").format.rowHeight = 42;
|
||||||
|
|
||||||
|
notes.showGridLines = false;
|
||||||
|
notes.getRange("A1:H1").merge();
|
||||||
|
notes.getRange("A1").values = [["核验口径与证据说明"]];
|
||||||
|
notes.getRange("A1:H1").format = titleFormat;
|
||||||
|
notes.getRange("A1:H1").format.rowHeight = 32;
|
||||||
|
notes.getRange("A3:H3").merge(); notes.getRange("A3").values = [["1. 核验口径"]]; notes.getRange("A3:H3").format = sectionFormat;
|
||||||
|
notes.getRange("A4:B8").values = [
|
||||||
|
["协议", "GB/T 32960"],
|
||||||
|
["仪表总里程字段", "gb32960.vehicle.total_mileage_km"],
|
||||||
|
["仪表总里程含义", "车辆仪表盘累计总里程,单位 km;不同协议累计值不可拼接。"],
|
||||||
|
["中台当日里程", "生产 vehicle_daily_mileage 的 GB32960 日统计值;本材料逐车求和后与原异常表TBOX合计核对。"],
|
||||||
|
["RAW相邻日差", "仅相邻两个自然日都存在RAW仪表读数时计算;缺任一日则留空,不把跨日缺口跳变塞进单日。"],
|
||||||
|
];
|
||||||
|
notes.getRange("A4:A8").format = labelFormat;
|
||||||
|
notes.getRange("B4:H8").merge(true);
|
||||||
|
notes.getRange("B4:H8").format = valueFormat;
|
||||||
|
notes.getRange("A10:H10").merge(); notes.getRange("A10").values = [["2. 证据分级"]]; notes.getRange("A10:H10").format = sectionFormat;
|
||||||
|
notes.getRange("A11:B14").values = [
|
||||||
|
["A-原始32960报文", "中台TDengine保存的完整GB32960十六进制报文,且解析结果明确包含仪表总里程字段。"],
|
||||||
|
["B-中台32960日统计", "中台日统计存在,但当日未检出含仪表总里程的RAW帧。"],
|
||||||
|
["C-历史日统计迁移", "来自历史生产库 lingniu_prod.ln_vehicle_day_mileage 的迁移记录,不是原始帧。"],
|
||||||
|
["D-无中台日记录", "当日中台日统计无记录,且现行RAW历史不可用或未检出。"],
|
||||||
|
];
|
||||||
|
notes.getRange("A11:A14").format = labelFormat;
|
||||||
|
notes.getRange("B11:H14").merge(true);
|
||||||
|
notes.getRange("B11:H14").format = valueFormat;
|
||||||
|
notes.getRange("A11:H11").format.fill = palette.green;
|
||||||
|
notes.getRange("A13:H13").format.fill = palette.yellow;
|
||||||
|
notes.getRange("A14:H14").format.fill = palette.gray;
|
||||||
|
|
||||||
|
notes.getRange("A16:H16").merge(); notes.getRange("A16").values = [["3. 数据来源与限制"]]; notes.getRange("A16:H16").format = sectionFormat;
|
||||||
|
notes.getRange("A17:B21").values = [
|
||||||
|
["原异常表", "/Users/lingniu/Documents/里程异常统计表_20251128-20260731-回复.xlsx"],
|
||||||
|
["中台日统计API", "http://115.29.187.205:20200/api/stats/daily-metrics"],
|
||||||
|
["中台RAW历史API", "http://115.29.187.205:20200/api/history/raw-frames"],
|
||||||
|
["RAW可查范围", "本次提取范围2026-07-01至2026-07-31;15车共取得334个含仪表总里程字段的完整原始帧。"],
|
||||||
|
["重要限制", "2026-07-01之前现行TDengine没有可查询RAW历史;只能提供历史生产库日统计迁移记录。对外申诉时应如实说明,并向原TSP/车厂补调更早期原始报文归档。"],
|
||||||
|
];
|
||||||
|
notes.getRange("A17:A21").format = labelFormat;
|
||||||
|
notes.getRange("B17:H21").merge(true);
|
||||||
|
notes.getRange("B17:H21").format = valueFormat;
|
||||||
|
notes.getRange("A21:H21").format.fill = palette.amber;
|
||||||
|
|
||||||
|
notes.getRange("A23:H23").merge(); notes.getRange("A23").values = [["4. 使用建议"]]; notes.getRange("A23:H23").format = sectionFormat;
|
||||||
|
notes.getRange("A24:H27").values = [
|
||||||
|
["① 先提交“申诉总览”,证明15车中台GB32960日里程合计与原TBOX合计一致。", null, null, null, null, null, null, null],
|
||||||
|
["② 每车附对应Sheet,重点引用A类RAW证据、事件时间、接收时间和完整HEX。", null, null, null, null, null, null, null],
|
||||||
|
["③ 对第三方缺失日,用完整日历中的D级空缺、前后仪表累计变化和原排查结论说明漏传。", null, null, null, null, null, null, null],
|
||||||
|
["④ 申诉若要求全区间逐日原始报文,需由原TSP/车厂补调2026-07-01之前归档;本材料未虚构缺失证据。", null, null, null, null, null, null, null],
|
||||||
|
];
|
||||||
|
for (let row = 24; row <= 27; row += 1) notes.getRange(`A${row}:H${row}`).merge();
|
||||||
|
notes.getRange("A24:H27").format = { ...valueFormat, fill: palette.blue2 };
|
||||||
|
notes.getRange("A4:H27").format.rowHeight = 30;
|
||||||
|
notes.getRange("A1:A27").format.columnWidth = 22;
|
||||||
|
for (let col = 1; col < 8; col += 1) notes.getRangeByIndexes(0, col, 27, 1).format.columnWidth = 18;
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
await fs.mkdir(previewDir, { recursive: true });
|
||||||
|
|
||||||
|
const inspection = {};
|
||||||
|
inspection.summary = (await workbook.inspect({ kind: "table", range: "申诉总览!A1:P20", include: "values,formulas", tableMaxRows: 24, tableMaxCols: 16, maxChars: 22000 })).ndjson;
|
||||||
|
inspection.formulaErrors = (await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" })).ndjson;
|
||||||
|
await fs.writeFile(path.join(outputDir, "verification.ndjson"), `${inspection.summary}\n${inspection.formulaErrors}\n`, "utf8");
|
||||||
|
|
||||||
|
const previewSheets = ["申诉总览", "口径与证据说明", ...data.vehicles.map((vehicle) => vehicleSheets.get(vehicle.vin).sheetName)];
|
||||||
|
for (let index = 0; index < previewSheets.length; index += 1) {
|
||||||
|
const sheetName = previewSheets[index];
|
||||||
|
const range = sheetName === "申诉总览" ? "A1:P20" : sheetName === "口径与证据说明" ? "A1:H27" : "A1:P24";
|
||||||
|
const preview = await workbook.render({ sheetName, range, scale: 1, format: "png" });
|
||||||
|
const safeName = sheetName.replaceAll(/[\\/:*?\[\]]/g, "_");
|
||||||
|
await fs.writeFile(path.join(previewDir, `${String(index + 1).padStart(2, "0")}-${safeName}.png`), new Uint8Array(await preview.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await SpreadsheetFile.exportXlsx(workbook);
|
||||||
|
await output.save(outputPath);
|
||||||
|
console.log(JSON.stringify({ outputPath, sheets: previewSheets.length, previewDir }, null, 2));
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
const baseUrl = "http://115.29.187.205:20200";
|
||||||
|
const outputPath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/evidence.json";
|
||||||
|
const vehicles = [
|
||||||
|
{ seq: 1, model: "18T", plate: "粤A05995F", vin: "LNXNEGRR7SR321370", sourceTboxKm: 26180.6, platformKm: 4208.9, lossKm: 21971.7, lossRatePct: 83.92, anomaly: "里程损失,tbox异常", appealKm: 41497.1, note: "区间内仪表盘开始里程是576.6,结束里程是41497.1,差值40920.5,小于申诉里程;2026-05-11到2026-07-28间的数据没上传,仪表盘里程差了36306.6,2025-12-25到2026-01-01间还存在里程跳变导致里程缺失" },
|
||||||
|
{ seq: 2, model: "18T", plate: "粤A05839F", vin: "LNXNEGRR8SR321376", sourceTboxKm: 41030.3, platformKm: 19763.6, lossKm: 21266.7, lossRatePct: 51.83, anomaly: "里程损失", appealKm: 41030.3, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,特别是7月份仪表里程从7月1号的16341.2到7月31号的41848.9,相差25507.7,计算的总里程只有8470" },
|
||||||
|
{ seq: 3, model: "18T", plate: "粤A09369F", vin: "LNXNEGRR6SR321473", sourceTboxKm: 40381.5, platformKm: 24994.0, lossKm: 15387.5, lossRatePct: 38.11, anomaly: "里程损失", appealKm: 40381.5, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 4, model: "18T", plate: "粤A06658F", vin: "LNXNEGRR5SR321397", sourceTboxKm: 52386.5, platformKm: 33064.5, lossKm: 19322.0, lossRatePct: 36.88, anomaly: "里程损失", appealKm: 52386.5, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 5, model: "18T", plate: "粤A05700F", vin: "LNXNEGRR8SR321393", sourceTboxKm: 60054.3, platformKm: 41352.9, lossKm: 18701.4, lossRatePct: 31.14, anomaly: "里程损失", appealKm: 60054.3, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 6, model: "18T", plate: "粤A05906F", vin: "LNXNEGRR2SR319462", sourceTboxKm: 37216.8, platformKm: 26711.4, lossKm: 10505.4, lossRatePct: 28.23, anomaly: "里程损失", appealKm: 37216.8, note: "最新数据只到2026-06-30,仪表盘最后里程为28638.5,没有7月份的数据;也存在很多数据缺失导致的里程缺失,例如2026-03-26到2026-04-01,2026-04-21到2026-05-01间没上传数据" },
|
||||||
|
{ seq: 7, model: "4.5T", plate: "粤AGP9346", vin: "LB9A32A28R0LS1722", sourceTboxKm: 32337.2, platformKm: 28685.4, lossKm: 3651.8, lossRatePct: 11.29, anomaly: "里程损失", appealKm: 32337.2, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 8, model: "4.5T", plate: "粤AGP2017", vin: "LB9A32A28R0LS1445", sourceTboxKm: 48164.9, platformKm: 44357.1, lossKm: 3807.8, lossRatePct: 7.91, anomaly: "里程损失", appealKm: 48164.9, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,而且区间内最后的仪表里程是2026-07-31的47337.5,小于申诉里程" },
|
||||||
|
{ seq: 9, model: "4.5T", plate: "粤AGP5165", vin: "LB9A32A25R0LS1385", sourceTboxKm: 32906.4, platformKm: 30302.3, lossKm: 2604.1, lossRatePct: 7.91, anomaly: "里程损失", appealKm: 32906.4, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,而且区间内最后的仪表里程是2026-07-30的31994.6,2026-07-31没有数据上传" },
|
||||||
|
{ seq: 10, model: "4.5T", plate: "粤AGP4355", vin: "LB9A32A26R0LS1606", sourceTboxKm: 55151.0, platformKm: 51525.8, lossKm: 3625.2, lossRatePct: 6.57, anomaly: "里程损失", appealKm: 55151.0, note: "区间内仪表盘开始里程是1898.6,结束里程是55229.6,差值53331,小于申诉里程,也存在很多数据缺失导致的里程缺失" },
|
||||||
|
{ seq: 11, model: "4.5T", plate: "粤AGP5646", vin: "LB9A32A22R0LS1702", sourceTboxKm: 39935.0, platformKm: 37319.8, lossKm: 2615.2, lossRatePct: 6.55, anomaly: "里程损失", appealKm: 39935.0, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 12, model: "4.5T", plate: "粤AGP3692", vin: "LB9A32A27R0LS1727", sourceTboxKm: 64195.0, platformKm: 60242.6, lossKm: 3952.4, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 64195.0, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 13, model: "4.5T", plate: "粤AGP9782", vin: "LB9A32A28R0LS1705", sourceTboxKm: 50216.4, platformKm: 47123.8, lossKm: 3092.6, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 50216.4, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,如:2026-05-21数据缺失导致里程少了1300多" },
|
||||||
|
{ seq: 14, model: "4.5T", plate: "粤AGP3027", vin: "LB9A32A22R0LS1599", sourceTboxKm: 31180.9, platformKm: 29260.1, lossKm: 1920.8, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 31180.9, note: "区间内仪表盘开始里程是933.5,结束里程是31245.3,差值30311.8,小于申诉里程,也存在一些数据缺失导致的里程跳变,统计里程缺失" },
|
||||||
|
{ seq: 15, model: "4.5T", plate: "粤AGP9751", vin: "LB9A32A28R0LS1574", sourceTboxKm: 17944.6, platformKm: 16861.0, lossKm: 1083.6, lossRatePct: 6.04, anomaly: "里程损失", appealKm: 17944.6, note: "区间内仪表盘开始里程是205.2,结束里程是17796.8,差值17591.6,小于申诉里程,也存在一些数据缺失导致的里程缺失" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function dateString(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(day, count) {
|
||||||
|
const date = new Date(`${day}T00:00:00Z`);
|
||||||
|
date.setUTCDate(date.getUTCDate() + count);
|
||||||
|
return dateString(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateRange(start, end) {
|
||||||
|
const out = [];
|
||||||
|
for (let day = start; day <= end; day = addDays(day, 1)) out.push(day);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, attempts = 4) {
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||||
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapConcurrent(items, concurrency, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let cursor = 0;
|
||||||
|
async function run() {
|
||||||
|
while (true) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
if (index >= items.length) return;
|
||||||
|
results[index] = await worker(items[index], index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDaily(vehicle) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
vin: vehicle.vin,
|
||||||
|
protocol: "GB32960",
|
||||||
|
dateFrom: "2025-11-28",
|
||||||
|
dateTo: "2026-07-31",
|
||||||
|
limit: "500",
|
||||||
|
includeTotal: "true",
|
||||||
|
});
|
||||||
|
const data = await fetchJson(`${baseUrl}/api/stats/daily-metrics?${params}`);
|
||||||
|
return data.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRawEvidence({ vehicle, day }) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
protocol: "GB32960",
|
||||||
|
vin: vehicle.vin,
|
||||||
|
dateFrom: `${day} 00:00:00`,
|
||||||
|
dateTo: `${addDays(day, 1)} 00:00:00`,
|
||||||
|
orderBy: "eventTime",
|
||||||
|
limit: "20",
|
||||||
|
includeFields: "true",
|
||||||
|
includePayload: "true",
|
||||||
|
fields: "gb32960.vehicle.total_mileage_km",
|
||||||
|
});
|
||||||
|
let data = await fetchJson(`${baseUrl}/api/history/raw-frames?${params}`);
|
||||||
|
let evidence = (data.items ?? []).find((item) => item?.parsed_fields?.["gb32960.vehicle.total_mileage_km"] != null && String(item.event_time ?? "").startsWith(day));
|
||||||
|
if (!evidence && (data.items ?? []).length === 20) {
|
||||||
|
params.set("limit", "100");
|
||||||
|
data = await fetchJson(`${baseUrl}/api/history/raw-frames?${params}`);
|
||||||
|
evidence = (data.items ?? []).find((item) => item?.parsed_fields?.["gb32960.vehicle.total_mileage_km"] != null && String(item.event_time ?? "").startsWith(day));
|
||||||
|
}
|
||||||
|
if (!evidence) return { day, status: "NO_RAW_ODOMETER_FRAME" };
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
status: "OK",
|
||||||
|
ts: evidence.ts ?? "",
|
||||||
|
eventTime: evidence.event_time ?? "",
|
||||||
|
receivedAt: evidence.received_at ?? "",
|
||||||
|
frameId: evidence.frame_id ?? "",
|
||||||
|
eventId: evidence.event_id ?? "",
|
||||||
|
messageIdHex: evidence.message_id_hex ?? "",
|
||||||
|
rawSizeBytes: evidence.raw_size_bytes ?? null,
|
||||||
|
rawHex: evidence.raw_hex ?? "",
|
||||||
|
parseStatus: evidence.parse_status ?? "",
|
||||||
|
parseError: evidence.parse_error ?? "",
|
||||||
|
sourceEndpoint: evidence.source_endpoint ?? "",
|
||||||
|
parsedTotalMileageKm: Number(evidence.parsed_fields["gb32960.vehicle.total_mileage_km"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyLists = await mapConcurrent(vehicles, 6, fetchDaily);
|
||||||
|
const rawDays = dateRange("2026-07-01", "2026-07-31");
|
||||||
|
const rawTasks = vehicles.flatMap((vehicle) => rawDays.map((day) => ({ vehicle, day })));
|
||||||
|
let completed = 0;
|
||||||
|
const rawResults = await mapConcurrent(rawTasks, 10, async (task) => {
|
||||||
|
const result = await fetchRawEvidence(task);
|
||||||
|
completed += 1;
|
||||||
|
if (completed % 50 === 0 || completed === rawTasks.length) console.log(`raw evidence ${completed}/${rawTasks.length}`);
|
||||||
|
return { vin: task.vehicle.vin, ...result };
|
||||||
|
});
|
||||||
|
|
||||||
|
const byVinRaw = new Map();
|
||||||
|
for (const item of rawResults) {
|
||||||
|
if (!byVinRaw.has(item.vin)) byVinRaw.set(item.vin, []);
|
||||||
|
byVinRaw.get(item.vin).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
baseUrl,
|
||||||
|
period: { start: "2025-11-28", end: "2026-07-31" },
|
||||||
|
protocol: "GB32960",
|
||||||
|
parsedMileageField: "gb32960.vehicle.total_mileage_km",
|
||||||
|
rawEvidenceAvailability: { start: "2026-07-01", end: "2026-07-31", note: "现行TDengine RAW历史自2026-07-01起;此前中台仅保留历史生产库日统计迁移记录,不能伪称原始帧。" },
|
||||||
|
vehicles: vehicles.map((vehicle, index) => ({
|
||||||
|
...vehicle,
|
||||||
|
dailyMetrics: dailyLists[index],
|
||||||
|
rawEvidence: (byVinRaw.get(vehicle.vin) ?? []).sort((a, b) => a.day.localeCompare(b.day)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
outputPath,
|
||||||
|
vehicles: result.vehicles.length,
|
||||||
|
dailyRows: result.vehicles.reduce((sum, item) => sum + item.dailyMetrics.length, 0),
|
||||||
|
rawEvidenceRows: result.vehicles.reduce((sum, item) => sum + item.rawEvidence.filter((row) => row.status === "OK").length, 0),
|
||||||
|
rawMissingDays: result.vehicles.reduce((sum, item) => sum + item.rawEvidence.filter((row) => row.status !== "OK").length, 0),
|
||||||
|
}, null, 2));
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const inputPath = "/Users/lingniu/Documents/里程异常统计表_20251128-20260731-回复.xlsx";
|
||||||
|
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/source-preview";
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const input = await FileBlob.load(inputPath);
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(input);
|
||||||
|
|
||||||
|
const overview = await workbook.inspect({
|
||||||
|
kind: "workbook,sheet,table,drawing",
|
||||||
|
maxChars: 20000,
|
||||||
|
tableMaxRows: 12,
|
||||||
|
tableMaxCols: 20,
|
||||||
|
tableMaxCellChars: 180,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(outputDir, "overview.ndjson"), overview.ndjson, "utf8");
|
||||||
|
|
||||||
|
const sheets = [];
|
||||||
|
for (let i = 0; ; i += 1) {
|
||||||
|
let sheet;
|
||||||
|
try {
|
||||||
|
sheet = workbook.worksheets.getItemAt(i);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!sheet) break;
|
||||||
|
const name = sheet.name;
|
||||||
|
const used = sheet.getUsedRange();
|
||||||
|
const usedAddress = used?.address ?? null;
|
||||||
|
const info = await workbook.inspect({
|
||||||
|
kind: "region,computedStyle,formula",
|
||||||
|
sheetId: name,
|
||||||
|
range: usedAddress ?? "A1:Z60",
|
||||||
|
maxChars: 30000,
|
||||||
|
tableMaxRows: 80,
|
||||||
|
tableMaxCols: 30,
|
||||||
|
tableMaxCellChars: 240,
|
||||||
|
options: { maxResults: 200 },
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(outputDir, `${String(i + 1).padStart(2, "0")}-${name.replaceAll(/[\\/:*?\[\]]/g, "_")}.ndjson`), info.ndjson, "utf8");
|
||||||
|
const preview = await workbook.render({ sheetName: name, autoCrop: "all", scale: 1, format: "png" });
|
||||||
|
const previewPath = path.join(outputDir, `${String(i + 1).padStart(2, "0")}-${name.replaceAll(/[\\/:*?\[\]]/g, "_")}.png`);
|
||||||
|
await fs.writeFile(previewPath, new Uint8Array(await preview.arrayBuffer()));
|
||||||
|
sheets.push({ index: i, name, usedAddress, previewPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(outputDir, "sheets.json"), JSON.stringify(sheets, null, 2), "utf8");
|
||||||
|
console.log(JSON.stringify(sheets, null, 2));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/preview")
|
||||||
|
output = source / "contact-sheet.jpg"
|
||||||
|
files = sorted(source.glob("*.png"))
|
||||||
|
thumb_w, thumb_h = 620, 360
|
||||||
|
label_h = 34
|
||||||
|
cols = 2
|
||||||
|
rows = (len(files) + cols - 1) // cols
|
||||||
|
canvas = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
for index, file in enumerate(files):
|
||||||
|
image = Image.open(file).convert("RGB")
|
||||||
|
image.thumbnail((thumb_w - 12, thumb_h - 12))
|
||||||
|
x0 = (index % cols) * thumb_w
|
||||||
|
y0 = (index // cols) * (thumb_h + label_h)
|
||||||
|
x = x0 + (thumb_w - image.width) // 2
|
||||||
|
y = y0 + 6
|
||||||
|
canvas.paste(image, (x, y))
|
||||||
|
draw.rectangle((x0, y0, x0 + thumb_w - 1, y0 + thumb_h + label_h - 1), outline="#B7C9D6", width=1)
|
||||||
|
draw.text((x0 + 8, y0 + thumb_h + 8), file.stem, fill="#1F2937", font=font)
|
||||||
|
|
||||||
|
canvas.save(output, quality=88)
|
||||||
|
print(output)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const workbookPath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/15辆车里程核验申诉材料_GB32960日里程及原始报文.xlsx";
|
||||||
|
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/final-qa";
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(workbookPath));
|
||||||
|
|
||||||
|
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 8000 });
|
||||||
|
const summary = await workbook.inspect({ kind: "table", range: "申诉总览!A1:P20", include: "values,formulas", tableMaxRows: 22, tableMaxCols: 16, maxChars: 18000 });
|
||||||
|
const vehicleTop = await workbook.inspect({ kind: "table", range: "02-粤A05839F!A4:P14", include: "values,formulas", tableMaxRows: 14, tableMaxCols: 16, maxChars: 12000 });
|
||||||
|
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "reimported final formula error scan" });
|
||||||
|
await fs.writeFile(path.join(outputDir, "final-inspect.ndjson"), `${sheets.ndjson}\n${summary.ndjson}\n${vehicleTop.ndjson}\n${errors.ndjson}\n`, "utf8");
|
||||||
|
|
||||||
|
const vehicleSheetNames = [
|
||||||
|
"01-粤A05995F", "02-粤A05839F", "03-粤A09369F", "04-粤A06658F", "05-粤A05700F",
|
||||||
|
"06-粤A05906F", "07-粤AGP9346", "08-粤AGP2017", "09-粤AGP5165", "10-粤AGP4355",
|
||||||
|
"11-粤AGP5646", "12-粤AGP3692", "13-粤AGP9782", "14-粤AGP3027", "15-粤AGP9751",
|
||||||
|
];
|
||||||
|
for (let index = 0; index < vehicleSheetNames.length; index += 1) {
|
||||||
|
const sheetName = vehicleSheetNames[index];
|
||||||
|
const preview = await workbook.render({ sheetName, range: "A232:P259", scale: 1, format: "png" });
|
||||||
|
await fs.writeFile(path.join(outputDir, `${String(index + 1).padStart(2, "0")}-${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ workbookPath, outputDir, sheetCount: 17, formulaErrorScan: errors.ndjson }, null, 2));
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import pymysql, pathlib, re, json
|
||||||
|
def connect():
|
||||||
|
env={}
|
||||||
|
for name in ['base.env','stat-writer.env']:
|
||||||
|
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
|
||||||
|
if '=' in line and not line.startswith('#'):
|
||||||
|
k,v=line.split('=',1);env[k]=v.strip().strip('\"').strip("'")
|
||||||
|
m=re.fullmatch(r'([^:]+):(.*?)@tcp\(([^:]+):(\d+)\)/([^?]+).*',env['MYSQL_DSN'])
|
||||||
|
assert m, 'Unexpected DSN format'
|
||||||
|
return pymysql.connect(user=m[1],password=m[2],host=m[3],port=int(m[4]),database=m[5],charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,autocommit=False)
|
||||||
|
if __name__=='__main__':
|
||||||
|
c=connect()
|
||||||
|
with c.cursor() as q:
|
||||||
|
for table in ['vehicle_daily_mileage_source','vehicle_daily_mileage','vehicle_data_source','vehicle_identifier','vehicle']:
|
||||||
|
q.execute('SHOW COLUMNS FROM '+table); print(table,json.dumps(q.fetchall(),default=str,ensure_ascii=False))
|
||||||
|
q.execute("SELECT * FROM vehicle_data_source WHERE platform_name LIKE '%赛格%' OR source_code LIKE '%seg%' OR source_ip LIKE '%seg%'"); print('SEG_SOURCES',json.dumps(q.fetchall(),default=str,ensure_ascii=False))
|
||||||
|
c.close()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from db import connect
|
||||||
|
import json, pathlib, collections, sys, decimal
|
||||||
|
root=pathlib.Path(__file__).parent
|
||||||
|
rows=json.loads((root/'rows.json').read_text())
|
||||||
|
c=connect(); q=c.cursor()
|
||||||
|
def query(sql,args=()):
|
||||||
|
q.execute(sql,args);return q.fetchall()
|
||||||
|
maps=collections.defaultdict(set)
|
||||||
|
for r in query("SELECT vin,plate FROM vehicle UNION SELECT vin,plate FROM vehicle_identifier WHERE enabled=1 UNION SELECT vin,plate FROM vehicle_identity_binding"):
|
||||||
|
if r['plate'] and r['vin']:maps[r['plate'].strip()].add(r['vin'])
|
||||||
|
unmapped=[]; mapped=[]
|
||||||
|
for r in rows:
|
||||||
|
vins=maps[r['plate']]
|
||||||
|
if len(vins)!=1:unmapped.append(dict(r,matching_vins=sorted(vins)));continue
|
||||||
|
mapped.append(dict(r,vin=next(iter(vins))))
|
||||||
|
assert len({(r['vin'],r['date']) for r in mapped})==len(mapped),'VIN/date collision'
|
||||||
|
source_ids=[r['id'] for r in query("SELECT id FROM vehicle_data_source WHERE protocol='JT808' AND (source_code='saige' OR platform_name LIKE '%%赛格%%')")]
|
||||||
|
existing=query("SELECT s.* FROM vehicle_daily_mileage_source s LEFT JOIN vehicle_data_source d ON d.protocol=s.protocol AND d.source_ip=s.source_ip WHERE s.protocol='JT808' AND s.stat_date BETWEEN '2026-07-01' AND '2026-08-31' AND (d.source_code='saige' OR d.platform_name LIKE '%%赛格%%' OR s.platform_name LIKE '%%赛格%%' OR s.source_key LIKE '%%saige%%')")
|
||||||
|
finals=query("SELECT * FROM vehicle_daily_mileage WHERE protocol='JT808' AND stat_date BETWEEN '2026-07-01' AND '2026-08-31'")
|
||||||
|
keys={(r['vin'],str(r['stat_date'])) for r in existing}
|
||||||
|
keys.update((r['vin'],str(r['stat_date'])) for r in finals if r['source_id'] in source_ids)
|
||||||
|
pending=[r for r in mapped if (r['vin'],r['date']) not in keys]
|
||||||
|
summary=dict(input_rows=len(rows),mapped_rows=len(mapped),unmapped_rows=len(unmapped),unmapped_plates=sorted({r['plate'] for r in unmapped}),existing_saige_skipped=len(mapped)-len(pending),insert_candidates=len(pending),insert_vehicles=len({r['vin'] for r in pending}),insert_total_km=str(sum((decimal.Decimal(r['km']) for r in pending),decimal.Decimal(0))))
|
||||||
|
(root/'preflight.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||||
|
(root/'unmapped.json').write_text(json.dumps(unmapped,ensure_ascii=False,indent=2))
|
||||||
|
(root/'pending.json').write_text(json.dumps(pending,ensure_ascii=False,indent=2))
|
||||||
|
print(json.dumps(summary,ensure_ascii=False))
|
||||||
|
if '--apply' not in sys.argv:
|
||||||
|
c.rollback(); c.close(); sys.exit(0)
|
||||||
|
# Historical dates only. Lock every affected vehicle/day source range and result
|
||||||
|
# in deterministic order before rechecking existence. No UPDATE statements.
|
||||||
|
c.rollback()
|
||||||
|
pending.sort(key=lambda r:(r['vin'],r['date']))
|
||||||
|
before=[]; locked=[]; skipped_race=0
|
||||||
|
for r in pending:
|
||||||
|
args=(r['vin'],r['date'])
|
||||||
|
ss=query("SELECT * FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808' FOR UPDATE",args)
|
||||||
|
ff=query("SELECT * FROM vehicle_daily_mileage WHERE vin=%s AND stat_date=%s AND protocol='JT808' FOR UPDATE",args)
|
||||||
|
before.append(dict(vin=r['vin'],date=r['date'],sources=ss,final=ff))
|
||||||
|
has=any(s['source_ip'] in ('222.66.200.68','58.33.87.196','manual-saige-excel') or 'saige' in s['source_key'].lower() or '赛格' in (s['platform_name'] or '') for s in ss) or any(f['source_id'] in source_ids for f in ff)
|
||||||
|
if has:skipped_race+=1;continue
|
||||||
|
locked.append((r,ff))
|
||||||
|
(root/'backup-before.json').write_text(json.dumps(before,default=str,ensure_ascii=False))
|
||||||
|
ds=query("SELECT * FROM vehicle_data_source WHERE protocol='JT808' AND source_ip='manual-saige-excel'")
|
||||||
|
if ds:
|
||||||
|
assert ds[0]['source_code']=='saige' and ds[0]['platform_name']=='赛格'
|
||||||
|
sid=ds[0]['id']
|
||||||
|
else:
|
||||||
|
q.execute("INSERT INTO vehicle_data_source (protocol,source_ip,platform_name,source_code,source_kind,trust_priority,enabled,remark) VALUES ('JT808','manual-saige-excel','赛格','saige','PLATFORM',100,1,%s)",('赛格Excel每日里程导入;仅补缺,不覆盖已有记录;报表未提供累计总里程',))
|
||||||
|
sid=q.lastrowid
|
||||||
|
inserted=[]; final_inserted=0
|
||||||
|
for r,ff in locked:
|
||||||
|
key='JT808:'+r['phone']+'@PLATFORM:saige_excel'
|
||||||
|
reason='manual_saige_excel_import_20260907;file='+r['file']+';row='+str(r['row'])
|
||||||
|
q.execute("""INSERT INTO vehicle_daily_mileage_source
|
||||||
|
(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,platform_name,daily_mileage_km,sample_count,quality_status,quality_reason,is_selected)
|
||||||
|
VALUES (%s,%s,'JT808',%s,'manual-saige-excel','manual-excel-import',%s,'赛格',%s,1,'OK',%s,%s)""",(r['vin'],r['date'],key,r['phone'],r['km'],reason,int(not ff)))
|
||||||
|
assert q.rowcount==1
|
||||||
|
if not ff:
|
||||||
|
q.execute("INSERT INTO vehicle_daily_mileage (vin,stat_date,protocol,source_id,daily_mileage_km) VALUES (%s,%s,'JT808',%s,%s)",(r['vin'],r['date'],sid,r['km']))
|
||||||
|
assert q.rowcount==1;final_inserted+=1
|
||||||
|
inserted.append(dict(r,source_key=key))
|
||||||
|
# Verify every old row is byte-for-byte equivalent as typed query results.
|
||||||
|
for b in before:
|
||||||
|
args=(b['vin'],b['date'])
|
||||||
|
now=query("SELECT * FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808'",args)
|
||||||
|
lookup={s['source_key']:s for s in now}
|
||||||
|
assert all(lookup[s['source_key']]==s for s in b['sources']), 'Existing source changed'
|
||||||
|
if b['final']:
|
||||||
|
assert query("SELECT * FROM vehicle_daily_mileage WHERE vin=%s AND stat_date=%s AND protocol='JT808'",args)==b['final'],'Existing final changed'
|
||||||
|
for r in inserted:
|
||||||
|
v=query("SELECT daily_mileage_km,latest_total_mileage_km,first_total_mileage_km FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808' AND source_key=%s",(r['vin'],r['date'],r['source_key']))
|
||||||
|
assert len(v)==1 and v[0]['daily_mileage_km']==decimal.Decimal(r['km']) and v[0]['latest_total_mileage_km'] is None and v[0]['first_total_mileage_km'] is None
|
||||||
|
result=dict(summary,inserted_sources=len(inserted),inserted_final_rows=final_inserted,preserved_existing_final_rows=len(locked)-final_inserted,concurrent_skipped=skipped_race,source_id=sid,existing_rows_verified_unchanged=True)
|
||||||
|
(root/'inserted.json').write_text(json.dumps(inserted,ensure_ascii=False))
|
||||||
|
c.commit()
|
||||||
|
result['committed']=True
|
||||||
|
(root/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
|
||||||
|
print(json.dumps(result,ensure_ascii=False))
|
||||||
|
c.close()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import xlrd, json, pathlib, decimal, hashlib, re
|
||||||
|
out=pathlib.Path(__file__).parent
|
||||||
|
rows=[]; summary=[]; seen={}
|
||||||
|
for p in sorted(pathlib.Path('/Users/lingniu/Documents').glob('里程统计报表*.xls')):
|
||||||
|
s=xlrd.open_workbook(str(p)).sheet_by_index(0)
|
||||||
|
meta={'file':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'rows':s.nrows-1}
|
||||||
|
if s.cell_value(0,2)!='日期':
|
||||||
|
meta.update(excluded='No daily date column',nonzero=sum(decimal.Decimal(str(s.cell_value(i,3)))!=0 for i in range(1,s.nrows)))
|
||||||
|
summary.append(meta); continue
|
||||||
|
dates=[]
|
||||||
|
for i in range(1,s.nrows):
|
||||||
|
plate,phone,day,km=map(lambda x:str(x).strip(),s.row_values(i))
|
||||||
|
import datetime
|
||||||
|
datetime.date.fromisoformat(day)
|
||||||
|
assert re.fullmatch(r'(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?',km), km
|
||||||
|
amount=decimal.Decimal(km.replace(',','')); assert amount.is_finite() and amount>=0
|
||||||
|
key=(plate,day)
|
||||||
|
assert key not in seen, ('duplicate',key)
|
||||||
|
seen[key]=amount; dates.append(day)
|
||||||
|
rows.append(dict(plate=plate,phone=phone,date=day,km=str(amount),file=p.name,row=i+1))
|
||||||
|
meta.update(date_min=min(dates),date_max=max(dates),vehicles=len({r['plate'] for r in rows if r['file']==p.name}))
|
||||||
|
summary.append(meta)
|
||||||
|
(out/'rows.json').write_text(json.dumps(rows,ensure_ascii=False))
|
||||||
|
(out/'input-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||||
|
print(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from db import connect
|
||||||
|
import pathlib,json,decimal,collections
|
||||||
|
p=pathlib.Path(__file__).parent
|
||||||
|
inserted=json.loads((p/'inserted.json').read_text())
|
||||||
|
c=connect();q=c.cursor()
|
||||||
|
q.execute("SELECT vin,stat_date,source_key,daily_mileage_km,latest_total_mileage_km FROM vehicle_daily_mileage_source WHERE source_ip='manual-saige-excel' AND stat_date BETWEEN '2026-07-01' AND '2026-08-31'")
|
||||||
|
actual={(r['vin'],str(r['stat_date']),r['source_key']):r for r in q.fetchall()}
|
||||||
|
months=collections.defaultdict(lambda:dict(rows=0,total_km=decimal.Decimal(0)))
|
||||||
|
for r in inserted:
|
||||||
|
a=actual[(r['vin'],r['date'],r['source_key'])]
|
||||||
|
assert a['daily_mileage_km']==decimal.Decimal(r['km']) and a['latest_total_mileage_km'] is None
|
||||||
|
m=months[r['date'][:7]];m['rows']+=1;m['total_km']+=a['daily_mileage_km']
|
||||||
|
v=dict(verified_rows=len(inserted),months=dict(months),verified_total_km=sum((m['total_km'] for m in months.values()),decimal.Decimal(0)))
|
||||||
|
(p/'verification.json').write_text(json.dumps(v,default=str,ensure_ascii=False,indent=2))
|
||||||
|
print(json.dumps(v,default=str,ensure_ascii=False));c.close()
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
|
||||||
|
|
||||||
|
const workspaceRoot = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
|
||||||
|
const workDir = path.join(
|
||||||
|
workspaceRoot,
|
||||||
|
"tmp/vehicle-mileage-selection-019fa1ac-db39-7933-bb0e-30dd63cc25bd",
|
||||||
|
);
|
||||||
|
const outputDir = path.join(
|
||||||
|
workspaceRoot,
|
||||||
|
"outputs/019fa1ac-db39-7933-bb0e-30dd63cc25bd",
|
||||||
|
);
|
||||||
|
const outputPath = path.join(
|
||||||
|
outputDir,
|
||||||
|
"江浙沪四类车型近三个月GPS里程清单_20260427-20260726.xlsx",
|
||||||
|
);
|
||||||
|
const masterPath = path.join(
|
||||||
|
workspaceRoot,
|
||||||
|
"outputs/jt808-provider-audit-20260717/oneos-vehicle-master.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateFrom = "2026-04-27";
|
||||||
|
const dateTo = "2026-07-26";
|
||||||
|
const periodDays = 91;
|
||||||
|
const queryAsOf = "2026-07-28";
|
||||||
|
const mileageProtocol = "JT808";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postProductionAPI(endpoint, body) {
|
||||||
|
const remoteCommand =
|
||||||
|
`. /opt/lingniu-vehicle-platform/env/access-tokens.env; ` +
|
||||||
|
`curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" ` +
|
||||||
|
`-H "Content-Type: application/json" --data-binary @- ` +
|
||||||
|
`http://127.0.0.1:20300${endpoint}`;
|
||||||
|
const result = spawnSync(
|
||||||
|
"ssh",
|
||||||
|
["-o", "BatchMode=yes", "root@115.29.187.205", remoteCommand],
|
||||||
|
{
|
||||||
|
input: JSON.stringify(body),
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Production API failed for ${endpoint}: ${result.stderr || result.stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(result.stdout);
|
||||||
|
if (!payload.data) {
|
||||||
|
throw new Error(`Production API returned no data for ${endpoint}`);
|
||||||
|
}
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function excelDate(isoDate) {
|
||||||
|
return new Date(`${isoDate}T00:00:00+08:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function datesBetween(start, end) {
|
||||||
|
const result = [];
|
||||||
|
const cursor = new Date(`${start}T00:00:00+08:00`);
|
||||||
|
const last = new Date(`${end}T00:00:00+08:00`);
|
||||||
|
while (cursor <= last) {
|
||||||
|
result.push(cursor.toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }));
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function residentLocation(rawCity) {
|
||||||
|
const parts = String(rawCity || "").split("-");
|
||||||
|
const province = parts[0] || "";
|
||||||
|
const city = parts.slice(1).join("-") || province;
|
||||||
|
return { province, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicleCategory(model) {
|
||||||
|
const normalized = String(model || "");
|
||||||
|
if (normalized.includes("4.5吨") && !normalized.includes("冷链")) {
|
||||||
|
return "4.5T普货";
|
||||||
|
}
|
||||||
|
if (normalized.includes("冷链")) return "冷链";
|
||||||
|
if (normalized.includes("18吨")) return "18T";
|
||||||
|
if (normalized.includes("49吨")) return "49T";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJiangZheHu(city) {
|
||||||
|
return ["江苏省-", "浙江省-", "上海市-"].some((prefix) =>
|
||||||
|
String(city || "").startsWith(prefix),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMasterVehicles() {
|
||||||
|
const source = JSON.parse(await fs.readFile(masterPath, "utf8"));
|
||||||
|
const rows = source.sheets?.[0]?.rows || [];
|
||||||
|
assert(rows.length > 1, "OneOS vehicle master is empty");
|
||||||
|
const headers = rows[0].values;
|
||||||
|
const headerIndex = Object.fromEntries(headers.map((value, index) => [value, index]));
|
||||||
|
const requiredHeaders = [
|
||||||
|
"车牌号",
|
||||||
|
"VIN",
|
||||||
|
"运营城市",
|
||||||
|
"品牌",
|
||||||
|
"型号",
|
||||||
|
"运营状态",
|
||||||
|
"车辆状态",
|
||||||
|
"客户名称",
|
||||||
|
];
|
||||||
|
for (const header of requiredHeaders) {
|
||||||
|
assert(headerIndex[header] !== undefined, `Missing master header: ${header}`);
|
||||||
|
}
|
||||||
|
const result = [];
|
||||||
|
for (const row of rows.slice(1)) {
|
||||||
|
const values = [...row.values, ...Array(headers.length).fill("")];
|
||||||
|
const cityRaw = values[headerIndex["运营城市"]];
|
||||||
|
const model = values[headerIndex["型号"]];
|
||||||
|
const category = vehicleCategory(model);
|
||||||
|
if (!category || !isJiangZheHu(cityRaw)) continue;
|
||||||
|
const vin = String(values[headerIndex["VIN"]] || "").trim();
|
||||||
|
const plate = String(values[headerIndex["车牌号"]] || "").trim();
|
||||||
|
if (!vin || !plate) continue;
|
||||||
|
const location = residentLocation(cityRaw);
|
||||||
|
result.push({
|
||||||
|
category,
|
||||||
|
plate,
|
||||||
|
vin,
|
||||||
|
brand: String(values[headerIndex["品牌"]] || "").trim(),
|
||||||
|
model: String(model || "").trim(),
|
||||||
|
province: location.province,
|
||||||
|
city: location.city,
|
||||||
|
cityRaw: String(cityRaw || "").trim(),
|
||||||
|
operationStatus: String(values[headerIndex["运营状态"]] || "").trim(),
|
||||||
|
vehicleStatus: String(values[headerIndex["车辆状态"]] || "").trim(),
|
||||||
|
customerName: ["租赁", "自营"].includes(
|
||||||
|
String(values[headerIndex["运营状态"]] || "").trim(),
|
||||||
|
)
|
||||||
|
? String(values[headerIndex["客户名称"]] || "").trim()
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryRanking(vins) {
|
||||||
|
if (vins.length === 0) return { ranking: [], asOf: "" };
|
||||||
|
return postProductionAPI("/api/v2/statistics/mileage", {
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
vins,
|
||||||
|
protocol: mileageProtocol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankingMap(data) {
|
||||||
|
return new Map((data.ranking || []).map((row) => [row.vin, row]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCategoryVehicles(category, candidates) {
|
||||||
|
const categoryCandidates = candidates.filter((row) => row.category === category);
|
||||||
|
assert(
|
||||||
|
categoryCandidates.length >= 10,
|
||||||
|
`${category} has only ${categoryCandidates.length} Jiang-Zhe-Hu candidates`,
|
||||||
|
);
|
||||||
|
const masterByVIN = new Map(categoryCandidates.map((row) => [row.vin, row]));
|
||||||
|
const suzhouVINs = categoryCandidates
|
||||||
|
.filter((row) => row.city === "苏州市")
|
||||||
|
.map((row) => row.vin);
|
||||||
|
const otherVINs = categoryCandidates
|
||||||
|
.filter((row) => row.city !== "苏州市")
|
||||||
|
.map((row) => row.vin);
|
||||||
|
const suzhouRanking = queryRanking(suzhouVINs);
|
||||||
|
const otherRanking = queryRanking(otherVINs);
|
||||||
|
const combined = [
|
||||||
|
...(suzhouRanking.ranking || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
suzhouPreferred: true,
|
||||||
|
})),
|
||||||
|
...(otherRanking.ranking || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
suzhouPreferred: false,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
assert(combined.length >= 10, `${category} has fewer than 10 vehicles with mileage`);
|
||||||
|
return combined.slice(0, 10).map((ranked, index) => ({
|
||||||
|
...masterByVIN.get(ranked.vin),
|
||||||
|
rank: index + 1,
|
||||||
|
sourcePeriodMileageKm: Number(ranked.mileageKm || 0),
|
||||||
|
sourceActiveDays: Number(ranked.activeDays || 0),
|
||||||
|
suzhouPreferred: ranked.suzhouPreferred,
|
||||||
|
statisticsAsOf: suzhouRanking.asOf || otherRanking.asOf || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryDailyMileage(vins) {
|
||||||
|
const items = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 1000;
|
||||||
|
let total = Number.POSITIVE_INFINITY;
|
||||||
|
while (offset < total) {
|
||||||
|
const page = postProductionAPI("/api/mileage/daily", {
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
vins,
|
||||||
|
protocol: mileageProtocol,
|
||||||
|
deduplicate: true,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
const pageItems = page.items || [];
|
||||||
|
items.push(...pageItems);
|
||||||
|
total = Number(page.total || 0);
|
||||||
|
offset += pageItems.length;
|
||||||
|
if (pageItems.length === 0) break;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleTitle(sheet, rangeAddress, title) {
|
||||||
|
const range = sheet.getRange(rangeAddress);
|
||||||
|
range.merge();
|
||||||
|
range.values = [[title]];
|
||||||
|
range.format = {
|
||||||
|
fill: "#0F4C5C",
|
||||||
|
font: { bold: true, color: "#FFFFFF", size: 16 },
|
||||||
|
verticalAlignment: "center",
|
||||||
|
horizontalAlignment: "left",
|
||||||
|
};
|
||||||
|
range.format.rowHeight = 34;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleHeader(range) {
|
||||||
|
range.format = {
|
||||||
|
fill: "#DCEEF2",
|
||||||
|
font: { bold: true, color: "#12343B" },
|
||||||
|
verticalAlignment: "center",
|
||||||
|
horizontalAlignment: "center",
|
||||||
|
wrapText: true,
|
||||||
|
borders: {
|
||||||
|
bottom: { style: "medium", color: "#78A7B2" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
range.format.rowHeight = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleNote(range) {
|
||||||
|
range.format = {
|
||||||
|
fill: "#F3F8FA",
|
||||||
|
font: { color: "#385A64", size: 10 },
|
||||||
|
wrapText: true,
|
||||||
|
verticalAlignment: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCategoryConditionalFormatting(range) {
|
||||||
|
range.conditionalFormats.addCustom('=$A5="4.5T普货"', {
|
||||||
|
fill: "#FFF4CC",
|
||||||
|
font: { bold: true, color: "#7A5300" },
|
||||||
|
});
|
||||||
|
range.conditionalFormats.addCustom('=$A5="冷链"', {
|
||||||
|
fill: "#DDF3FF",
|
||||||
|
font: { bold: true, color: "#075985" },
|
||||||
|
});
|
||||||
|
range.conditionalFormats.addCustom('=$A5="18T"', {
|
||||||
|
fill: "#E8E1FF",
|
||||||
|
font: { bold: true, color: "#5B21B6" },
|
||||||
|
});
|
||||||
|
range.conditionalFormats.addCustom('=$A5="49T"', {
|
||||||
|
fill: "#DFF5E6",
|
||||||
|
font: { bold: true, color: "#166534" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterVehicles = await loadMasterVehicles();
|
||||||
|
const categories = ["4.5T普货", "冷链", "18T", "49T"];
|
||||||
|
const selectedVehicles = categories.flatMap((category) =>
|
||||||
|
selectCategoryVehicles(category, masterVehicles),
|
||||||
|
);
|
||||||
|
assert(selectedVehicles.length === 40, "Expected exactly 40 selected vehicles");
|
||||||
|
assert(
|
||||||
|
new Set(selectedVehicles.map((row) => row.vin)).size === 40,
|
||||||
|
"Selected VINs are not unique",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailySourceRows = queryDailyMileage(selectedVehicles.map((row) => row.vin));
|
||||||
|
const dailyByVehicleDate = new Map();
|
||||||
|
for (const row of dailySourceRows) {
|
||||||
|
assert(
|
||||||
|
row.source === mileageProtocol,
|
||||||
|
`Unexpected mileage source for ${row.vin} on ${row.date}: ${row.source}`,
|
||||||
|
);
|
||||||
|
const key = `${row.vin}|${row.date}`;
|
||||||
|
assert(!dailyByVehicleDate.has(key), `Duplicate deduplicated daily row: ${key}`);
|
||||||
|
dailyByVehicleDate.set(key, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = datesBetween(dateFrom, dateTo);
|
||||||
|
assert(dates.length === periodDays, `Expected ${periodDays} dates, got ${dates.length}`);
|
||||||
|
|
||||||
|
const dailyRows = [];
|
||||||
|
for (const vehicle of selectedVehicles) {
|
||||||
|
for (const date of dates) {
|
||||||
|
const source = dailyByVehicleDate.get(`${vehicle.vin}|${date}`);
|
||||||
|
dailyRows.push({
|
||||||
|
date,
|
||||||
|
...vehicle,
|
||||||
|
dailyMileageKm: source ? Number(source.dailyMileageKm || 0) : null,
|
||||||
|
source: source?.source || "",
|
||||||
|
recordStatus: source ? "有记录" : "缺失",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(dailyRows.length === 40 * periodDays, "Daily row count mismatch");
|
||||||
|
|
||||||
|
for (const vehicle of selectedVehicles) {
|
||||||
|
const computed = dailyRows
|
||||||
|
.filter((row) => row.vin === vehicle.vin)
|
||||||
|
.reduce((sum, row) => sum + (row.dailyMileageKm ?? 0), 0);
|
||||||
|
const difference = Math.abs(computed - vehicle.sourcePeriodMileageKm);
|
||||||
|
assert(
|
||||||
|
difference < 0.02,
|
||||||
|
`${vehicle.plate} total mismatch: daily=${computed}, stats=${vehicle.sourcePeriodMileageKm}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = Workbook.create();
|
||||||
|
const notesSheet = workbook.worksheets.add("口径说明");
|
||||||
|
const summarySheet = workbook.worksheets.add("车辆汇总");
|
||||||
|
const dailySheet = workbook.worksheets.add("每日里程");
|
||||||
|
|
||||||
|
for (const sheet of [notesSheet, summarySheet, dailySheet]) {
|
||||||
|
sheet.showGridLines = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 口径说明
|
||||||
|
styleTitle(notesSheet, "A1:H1", "江浙沪四类车型近三个月 GPS 里程清单");
|
||||||
|
notesSheet.getRange("A3:B11").values = [
|
||||||
|
["项目", "口径"],
|
||||||
|
["统计区间", `${dateFrom} 至 ${dateTo}(${periodDays} 个自然日,完整日)`],
|
||||||
|
["区域范围", "常驻市(OneOS 运营城市)属于江苏、浙江、上海"],
|
||||||
|
["选车规则", "每类先按苏州区间总里程降序选取,不足 10 辆时按江浙沪其他城市区间总里程降序补齐"],
|
||||||
|
["车型定义", "4.5T普货=型号含4.5吨且不含冷链;冷链=型号含冷链;18T=型号含18吨;49T=型号含49吨"],
|
||||||
|
["里程口径", "仅使用 JT808 定位终端/GPS 侧累计里程计算,不使用 GB32960、宇通 MQTT 等仪表/车端累计里程"],
|
||||||
|
["缺失处理", "逐日表保留全部车辆×日期组合;无生产记录的日期留空,并标记为“缺失”,不擅自按0计算"],
|
||||||
|
["主数据快照", "OneOS车辆信息快照:2026-07-17;常驻市取“运营城市”字段"],
|
||||||
|
["里程数据截至", `${queryAsOf} 查询,统计截止至 ${dateTo};来源协议固定为 ${mileageProtocol}`],
|
||||||
|
];
|
||||||
|
styleHeader(notesSheet.getRange("A3:B3"));
|
||||||
|
styleNote(notesSheet.getRange("A4:B11"));
|
||||||
|
notesSheet.getRange("A4:A11").format.font = { bold: true, color: "#12343B" };
|
||||||
|
notesSheet.getRange("A3:B11").format.borders = {
|
||||||
|
outside: { style: "thin", color: "#B9D3DA" },
|
||||||
|
insideHorizontal: { style: "thin", color: "#DCE8EC" },
|
||||||
|
};
|
||||||
|
notesSheet.getRange("A13:E18").values = [
|
||||||
|
["分类", "车辆数", "苏州优先数", "GPS区间总里程(km)", "GPS日均里程(km/天)"],
|
||||||
|
["4.5T普货", null, null, null, null],
|
||||||
|
["冷链", null, null, null, null],
|
||||||
|
["18T", null, null, null, null],
|
||||||
|
["49T", null, null, null, null],
|
||||||
|
["合计", null, null, null, null],
|
||||||
|
];
|
||||||
|
styleHeader(notesSheet.getRange("A13:E13"));
|
||||||
|
notesSheet.getRange("B14").formulas = [["=COUNTIF('车辆汇总'!$A$5:$A$44,A14)"]];
|
||||||
|
notesSheet.getRange("B14:B17").fillDown();
|
||||||
|
notesSheet.getRange("C14").formulas = [
|
||||||
|
["=COUNTIFS('车辆汇总'!$A$5:$A$44,A14,'车辆汇总'!$I$5:$I$44,\"苏州市\")"],
|
||||||
|
];
|
||||||
|
notesSheet.getRange("C14:C17").fillDown();
|
||||||
|
notesSheet.getRange("D14").formulas = [
|
||||||
|
["=SUMIF('车辆汇总'!$A$5:$A$44,A14,'车辆汇总'!$O$5:$O$44)"],
|
||||||
|
];
|
||||||
|
notesSheet.getRange("D14:D17").fillDown();
|
||||||
|
notesSheet.getRange("E14").formulas = [["=D14/(B14*'车辆汇总'!$N$5)"]];
|
||||||
|
notesSheet.getRange("E14:E17").fillDown();
|
||||||
|
notesSheet.getRange("B18").formulas = [["=SUM(B14:B17)"]];
|
||||||
|
notesSheet.getRange("C18").formulas = [["=SUM(C14:C17)"]];
|
||||||
|
notesSheet.getRange("D18").formulas = [["=SUM(D14:D17)"]];
|
||||||
|
notesSheet.getRange("E18").formulas = [["=D18/(B18*'车辆汇总'!$N$5)"]];
|
||||||
|
notesSheet.getRange("D14:E18").format.numberFormat = "#,##0.0";
|
||||||
|
notesSheet.getRange("A18:E18").format = {
|
||||||
|
fill: "#E8F1F4",
|
||||||
|
font: { bold: true, color: "#12343B" },
|
||||||
|
borders: { top: { style: "double", color: "#78A7B2" } },
|
||||||
|
};
|
||||||
|
notesSheet.getRange("A13:E18").format.borders = {
|
||||||
|
outside: { style: "thin", color: "#B9D3DA" },
|
||||||
|
insideHorizontal: { style: "thin", color: "#DCE8EC" },
|
||||||
|
};
|
||||||
|
notesSheet.getRange("A:A").format.columnWidth = 18;
|
||||||
|
notesSheet.getRange("B:B").format.columnWidth = 64;
|
||||||
|
notesSheet.getRange("C:E").format.columnWidth = 18;
|
||||||
|
notesSheet.getRange("A4:B11").format.rowHeight = 34;
|
||||||
|
notesSheet.freezePanes.freezeRows(3);
|
||||||
|
|
||||||
|
// 车辆汇总
|
||||||
|
styleTitle(summarySheet, "A1:Q1", "四类车型各10辆|近三个月 GPS 里程汇总");
|
||||||
|
summarySheet.getRange("A2:Q2").merge();
|
||||||
|
summarySheet.getRange("A2").values = [[
|
||||||
|
`范围:江浙沪常驻车辆;苏州优先;统计区间 ${dateFrom} 至 ${dateTo}。仅使用 JT808 GPS 里程,区间总里程由“每日里程”明细公式汇总。`,
|
||||||
|
]];
|
||||||
|
styleNote(summarySheet.getRange("A2:Q2"));
|
||||||
|
const summaryHeaders = [
|
||||||
|
"分类",
|
||||||
|
"类内排名",
|
||||||
|
"车牌",
|
||||||
|
"VIN",
|
||||||
|
"品牌",
|
||||||
|
"型号",
|
||||||
|
"常驻省",
|
||||||
|
"常驻城市原值",
|
||||||
|
"常驻市",
|
||||||
|
"苏州优先",
|
||||||
|
"运营状态",
|
||||||
|
"车辆状态",
|
||||||
|
"客户名称",
|
||||||
|
"区间天数",
|
||||||
|
"GPS区间总里程(km)",
|
||||||
|
"GPS日均里程(km/天)",
|
||||||
|
"有记录天数",
|
||||||
|
];
|
||||||
|
summarySheet.getRange("A4:Q4").values = [summaryHeaders];
|
||||||
|
styleHeader(summarySheet.getRange("A4:Q4"));
|
||||||
|
const summaryValues = selectedVehicles.map((vehicle) => [
|
||||||
|
vehicle.category,
|
||||||
|
vehicle.rank,
|
||||||
|
vehicle.plate,
|
||||||
|
vehicle.vin,
|
||||||
|
vehicle.brand,
|
||||||
|
vehicle.model,
|
||||||
|
vehicle.province,
|
||||||
|
vehicle.cityRaw,
|
||||||
|
vehicle.city,
|
||||||
|
vehicle.suzhouPreferred ? "是" : "否",
|
||||||
|
vehicle.operationStatus,
|
||||||
|
vehicle.vehicleStatus,
|
||||||
|
vehicle.customerName,
|
||||||
|
periodDays,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
summarySheet.getRange(`A5:Q${4 + summaryValues.length}`).values = summaryValues;
|
||||||
|
const dailyEndRow = 4 + dailyRows.length;
|
||||||
|
summarySheet.getRange("O5").formulas = [[
|
||||||
|
`=SUMIFS('每日里程'!$J$5:$J$${dailyEndRow},'每日里程'!$E$5:$E$${dailyEndRow},D5)`,
|
||||||
|
]];
|
||||||
|
summarySheet.getRange("O5:O44").fillDown();
|
||||||
|
summarySheet.getRange("P5").formulas = [["=O5/N5"]];
|
||||||
|
summarySheet.getRange("P5:P44").fillDown();
|
||||||
|
summarySheet.getRange("Q5").formulas = [[
|
||||||
|
`=COUNTIFS('每日里程'!$E$5:$E$${dailyEndRow},D5,'每日里程'!$L$5:$L$${dailyEndRow},"有记录")`,
|
||||||
|
]];
|
||||||
|
summarySheet.getRange("Q5:Q44").fillDown();
|
||||||
|
summarySheet.getRange("B5:B44").format.numberFormat = "0";
|
||||||
|
summarySheet.getRange("N5:N44").format.numberFormat = "0";
|
||||||
|
summarySheet.getRange("O5:P44").format.numberFormat = "#,##0.0";
|
||||||
|
summarySheet.getRange("Q5:Q44").format.numberFormat = "0";
|
||||||
|
summarySheet.getRange("A5:Q44").format.verticalAlignment = "center";
|
||||||
|
summarySheet.getRange("A5:Q44").format.borders = {
|
||||||
|
insideHorizontal: { style: "thin", color: "#E2ECEF" },
|
||||||
|
};
|
||||||
|
addCategoryConditionalFormatting(summarySheet.getRange("A5:A44"));
|
||||||
|
summarySheet.getRange("O5:O44").conditionalFormats.add("dataBar", {
|
||||||
|
color: "#2A9D8F",
|
||||||
|
gradient: true,
|
||||||
|
});
|
||||||
|
summarySheet.getRange("J5:J44").conditionalFormats.add("containsText", {
|
||||||
|
text: "是",
|
||||||
|
format: {
|
||||||
|
fill: "#E5F7ED",
|
||||||
|
font: { bold: true, color: "#166534" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summaryTable = summarySheet.tables.add("A4:Q44", true, "VehicleMileageSummary");
|
||||||
|
summaryTable.style = "TableStyleMedium2";
|
||||||
|
summaryTable.showFilterButton = true;
|
||||||
|
summarySheet.freezePanes.freezeRows(4);
|
||||||
|
summarySheet.freezePanes.freezeColumns(4);
|
||||||
|
const summaryWidths = [
|
||||||
|
14, 10, 14, 22, 12, 30, 12, 20, 12, 10, 12, 12, 30, 10, 18, 18, 14,
|
||||||
|
];
|
||||||
|
summaryWidths.forEach((width, index) => {
|
||||||
|
summarySheet.getRangeByIndexes(0, index, 44, 1).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
summarySheet.getRange("A5:Q44").format.rowHeight = 23;
|
||||||
|
|
||||||
|
// 每日里程
|
||||||
|
styleTitle(dailySheet, "A1:L1", "40辆车逐日 GPS 里程明细");
|
||||||
|
dailySheet.getRange("A2:L2").merge();
|
||||||
|
dailySheet.getRange("A2").values = [[
|
||||||
|
`共 ${selectedVehicles.length} 辆 × ${periodDays} 天 = ${dailyRows.length.toLocaleString("zh-CN")} 行;仅统计 JT808 GPS 里程,缺失日期留空并单独标记。`,
|
||||||
|
]];
|
||||||
|
styleNote(dailySheet.getRange("A2:L2"));
|
||||||
|
const dailyHeaders = [
|
||||||
|
"日期",
|
||||||
|
"分类",
|
||||||
|
"类内排名",
|
||||||
|
"车牌",
|
||||||
|
"VIN",
|
||||||
|
"品牌",
|
||||||
|
"型号",
|
||||||
|
"常驻市",
|
||||||
|
"客户名称",
|
||||||
|
"GPS每日里程(km)",
|
||||||
|
"来源",
|
||||||
|
"记录状态",
|
||||||
|
];
|
||||||
|
dailySheet.getRange("A4:L4").values = [dailyHeaders];
|
||||||
|
styleHeader(dailySheet.getRange("A4:L4"));
|
||||||
|
const dailyValues = dailyRows.map((row) => [
|
||||||
|
excelDate(row.date),
|
||||||
|
row.category,
|
||||||
|
row.rank,
|
||||||
|
row.plate,
|
||||||
|
row.vin,
|
||||||
|
row.brand,
|
||||||
|
row.model,
|
||||||
|
row.city,
|
||||||
|
row.customerName,
|
||||||
|
row.dailyMileageKm,
|
||||||
|
row.source,
|
||||||
|
row.recordStatus,
|
||||||
|
]);
|
||||||
|
dailySheet.getRange(`A5:L${dailyEndRow}`).values = dailyValues;
|
||||||
|
dailySheet.getRange(`A5:A${dailyEndRow}`).format.numberFormat = "yyyy-mm-dd";
|
||||||
|
dailySheet.getRange(`C5:C${dailyEndRow}`).format.numberFormat = "0";
|
||||||
|
dailySheet.getRange(`J5:J${dailyEndRow}`).format.numberFormat = "#,##0.0";
|
||||||
|
dailySheet.getRange(`A5:L${dailyEndRow}`).format.borders = {
|
||||||
|
insideHorizontal: { style: "thin", color: "#EDF2F4" },
|
||||||
|
};
|
||||||
|
dailySheet.getRange(`L5:L${dailyEndRow}`).conditionalFormats.add("containsText", {
|
||||||
|
text: "缺失",
|
||||||
|
format: {
|
||||||
|
fill: "#FFF0F0",
|
||||||
|
font: { bold: true, color: "#B42318" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dailySheet.getRange(`J5:J${dailyEndRow}`).conditionalFormats.add("dataBar", {
|
||||||
|
color: "#5CA4A9",
|
||||||
|
gradient: true,
|
||||||
|
});
|
||||||
|
const dailyTable = dailySheet.tables.add(
|
||||||
|
`A4:L${dailyEndRow}`,
|
||||||
|
true,
|
||||||
|
"VehicleDailyMileage",
|
||||||
|
);
|
||||||
|
dailyTable.style = "TableStyleMedium2";
|
||||||
|
dailyTable.showFilterButton = true;
|
||||||
|
dailySheet.freezePanes.freezeRows(4);
|
||||||
|
dailySheet.freezePanes.freezeColumns(5);
|
||||||
|
const dailyWidths = [14, 14, 10, 14, 22, 12, 30, 12, 30, 18, 16, 12];
|
||||||
|
dailyWidths.forEach((width, index) => {
|
||||||
|
dailySheet.getRangeByIndexes(0, index, dailyEndRow, 1).format.columnWidth = width;
|
||||||
|
});
|
||||||
|
dailySheet.getRange(`A5:L${dailyEndRow}`).format.rowHeight = 21;
|
||||||
|
|
||||||
|
// Compact verification before export.
|
||||||
|
const summaryInspect = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
range: "车辆汇总!A1:Q16",
|
||||||
|
include: "values,formulas",
|
||||||
|
tableMaxRows: 16,
|
||||||
|
tableMaxCols: 17,
|
||||||
|
maxChars: 12000,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(workDir, "summary-inspect.ndjson"), summaryInspect.ndjson);
|
||||||
|
|
||||||
|
const dailyInspect = await workbook.inspect({
|
||||||
|
kind: "table",
|
||||||
|
range: "每日里程!A1:L20",
|
||||||
|
include: "values,formulas",
|
||||||
|
tableMaxRows: 20,
|
||||||
|
tableMaxCols: 12,
|
||||||
|
maxChars: 12000,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(workDir, "daily-inspect.ndjson"), dailyInspect.ndjson);
|
||||||
|
|
||||||
|
const formulaErrors = await workbook.inspect({
|
||||||
|
kind: "match",
|
||||||
|
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
|
||||||
|
options: { useRegex: true, maxResults: 300 },
|
||||||
|
summary: "final formula error scan",
|
||||||
|
maxChars: 12000,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(workDir, "formula-errors.ndjson"), formulaErrors.ndjson);
|
||||||
|
|
||||||
|
for (const [sheetName, range, fileName] of [
|
||||||
|
["口径说明", "A1:H19", "preview-notes.png"],
|
||||||
|
["车辆汇总", "A1:Q18", "preview-summary.png"],
|
||||||
|
["每日里程", "A1:L26", "preview-daily.png"],
|
||||||
|
]) {
|
||||||
|
const preview = await workbook.render({
|
||||||
|
sheetName,
|
||||||
|
range,
|
||||||
|
scale: 1.25,
|
||||||
|
format: "png",
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workDir, fileName),
|
||||||
|
new Uint8Array(await preview.arrayBuffer()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
const output = await SpreadsheetFile.exportXlsx(workbook);
|
||||||
|
await output.save(outputPath);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
outputPath,
|
||||||
|
selected: selectedVehicles.map((vehicle) => ({
|
||||||
|
category: vehicle.category,
|
||||||
|
rank: vehicle.rank,
|
||||||
|
plate: vehicle.plate,
|
||||||
|
vin: vehicle.vin,
|
||||||
|
brand: vehicle.brand,
|
||||||
|
model: vehicle.model,
|
||||||
|
residentCity: vehicle.city,
|
||||||
|
suzhouPreferred: vehicle.suzhouPreferred,
|
||||||
|
periodMileageKm: vehicle.sourcePeriodMileageKm,
|
||||||
|
})),
|
||||||
|
sourceDailyRows: dailySourceRows.length,
|
||||||
|
expandedDailyRows: dailyRows.length,
|
||||||
|
missingDailyRows: dailyRows.filter((row) => row.recordStatus === "缺失").length,
|
||||||
|
customerFilledVehicles: selectedVehicles.filter((row) => row.customerName).length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if e := run(); e != nil {
|
||||||
|
log.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func run() error {
|
||||||
|
mode := flag.String("mode", "status", "status, backfill, resolve, or live")
|
||||||
|
from := flag.String("from", "", "first historical date, defaults to first hydrogen day")
|
||||||
|
to := flag.String("to", "", "last historical date, defaults to yesterday")
|
||||||
|
concurrency := flag.Int("concurrency", 3, "maximum concurrent geocoding requests (1-8)")
|
||||||
|
flag.Parse()
|
||||||
|
cfg := config.Load()
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
db, e := sql.Open("mysql", cfg.MySQLDSN)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
db.SetMaxOpenConns(12)
|
||||||
|
if *from == "" {
|
||||||
|
if e = db.QueryRowContext(ctx, `SELECT COALESCE(DATE_FORMAT(MIN(stat_date),'%Y-%m-%d'),'') FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN'`).Scan(from); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *to == "" {
|
||||||
|
*to = time.Now().In(dailygeo.Shanghai).AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
first, e := time.ParseInLocation("2006-01-02", *from, dailygeo.Shanghai)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
last, e := time.ParseInLocation("2006-01-02", *to, dailygeo.Shanghai)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if first.After(last) {
|
||||||
|
return fmt.Errorf("invalid date range")
|
||||||
|
}
|
||||||
|
if *mode == "status" {
|
||||||
|
return printStatus(ctx, db, *from, *to)
|
||||||
|
}
|
||||||
|
if cfg.AMapAPIKey == "" {
|
||||||
|
return fmt.Errorf("AMAP_API_KEY required")
|
||||||
|
}
|
||||||
|
td, e := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer td.Close()
|
||||||
|
td.SetMaxOpenConns(2)
|
||||||
|
w := dailygeo.New(db, td, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
|
||||||
|
w.Concurrency = *concurrency
|
||||||
|
if *mode == "live" {
|
||||||
|
w.Run(ctx)
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if *mode != "backfill" && *mode != "resolve" {
|
||||||
|
return fmt.Errorf("invalid mode")
|
||||||
|
}
|
||||||
|
return w.WithLock(ctx, "vehicle_daily_geography_backfill", func() error {
|
||||||
|
if *mode == "backfill" {
|
||||||
|
for d := first; !d.After(last); d = d.AddDate(0, 0, 1) {
|
||||||
|
rctx, c := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
n, e := w.RefreshDay(rctx, d.Format("2006-01-02"))
|
||||||
|
c()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
log.Printf("daily_geography_seed date=%s points=%d", d.Format("2006-01-02"), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
n, failed, e := w.ResolvePending(ctx, *from, *to, 1000)
|
||||||
|
log.Printf("daily_geography_batch resolved=%d failed=%d error=%v", n, failed, e)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if e != nil && failed == 0 {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if n+failed == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return printStatus(ctx, db, *from, *to)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func printStatus(ctx context.Context, db *sql.DB, from, to string) error {
|
||||||
|
rows, e := db.QueryContext(ctx, `SELECT status,COUNT(*) FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? GROUP BY status`, from, to)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
counts := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s string
|
||||||
|
var n int
|
||||||
|
if e = rows.Scan(&s, &n); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
counts[s] = n
|
||||||
|
}
|
||||||
|
if e = rows.Err(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
var missing int
|
||||||
|
e = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_daily_energy h LEFT JOIN vehicle_daily_geography g ON g.vin=h.vin COLLATE utf8mb4_unicode_ci AND g.stat_date=h.stat_date WHERE h.energy_type='HYDROGEN' AND h.stat_date BETWEEN ? AND ? AND g.vin IS NULL`, from, to).Scan(&missing)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return json.NewEncoder(os.Stdout).Encode(map[string]any{"from": from, "to": to, "counts": counts, "hydrogenDaysMissing": missing})
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Read-only production verification of the same repository used by HTTP APIs.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
start := flag.String("start", "2026-09-11", "first date")
|
||||||
|
end := flag.String("end", "2026-09-15", "last date")
|
||||||
|
vin := flag.String("vins", "", "VINs, empty = all current vehicles")
|
||||||
|
flag.Parse()
|
||||||
|
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||||
|
must(err)
|
||||||
|
defer db.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
vins := strings.Split(*vin, ",")
|
||||||
|
if *vin == "" {
|
||||||
|
vins = nil
|
||||||
|
rows, e := db.QueryContext(ctx, "SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin<>''")
|
||||||
|
must(e)
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
must(rows.Scan(&v))
|
||||||
|
vins = append(vins, v)
|
||||||
|
}
|
||||||
|
must(rows.Err())
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
r := openplatform.NewMySQLRepository(db)
|
||||||
|
began := time.Now()
|
||||||
|
priorities := []string{"GB32960", "YUTONG_MQTT"}
|
||||||
|
all, err := r.ReconciledMileageRange(ctx, vins, *start, *end, priorities)
|
||||||
|
must(err)
|
||||||
|
first, e := time.Parse("2006-01-02", *start)
|
||||||
|
must(e)
|
||||||
|
last, e := time.Parse("2006-01-02", *end)
|
||||||
|
must(e)
|
||||||
|
mismatches := 0
|
||||||
|
differences := 0
|
||||||
|
normal := 0
|
||||||
|
qualities := map[string]int{}
|
||||||
|
samples := map[string]openplatform.DailyMileage{}
|
||||||
|
for date := first; !date.After(last); date = date.AddDate(0, 0, 1) {
|
||||||
|
d := date.Format("2006-01-02")
|
||||||
|
single, e := r.ReconciledMileageRange(ctx, vins, d, d, priorities)
|
||||||
|
must(e)
|
||||||
|
for _, v := range vins {
|
||||||
|
key := v + "\x00" + d
|
||||||
|
value, ok := all[key]
|
||||||
|
one, exists := single[key]
|
||||||
|
if ok != exists || !reflect.DeepEqual(value, one) {
|
||||||
|
differences++
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qualities[value.DataQuality]++
|
||||||
|
prev, hasPrev := all[v+"\x00"+date.AddDate(0, 0, -1).Format("2006-01-02")]
|
||||||
|
if value.DataQuality == "" || value.DataQuality == "CARRIED_FORWARD" {
|
||||||
|
normal++
|
||||||
|
if hasPrev && math.Abs(value.TotalMileageKm-prev.TotalMileageKm-value.MileageKm) > 0.001 {
|
||||||
|
mismatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(vins) < 10 {
|
||||||
|
samples[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
must(json.NewEncoder(os.Stdout).Encode(map[string]any{"vehicles": len(vins), "rows": len(all), "normal": normal, "qualities": qualities, "reconciliationMismatches": mismatches, "singleRangeDifferences": differences, "samples": samples, "elapsed": time.Since(began).String()}))
|
||||||
|
if mismatches > 0 || differences > 0 {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func must(e error) {
|
||||||
|
if e != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, e)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
apply := flag.Bool("apply", false, "apply corrections after writing a durable backup")
|
||||||
|
backup := flag.String("backup", "", "new backup JSON path, required with --apply")
|
||||||
|
flag.Parse()
|
||||||
|
if *apply && *backup == "" {
|
||||||
|
panic("--backup is required with --apply")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
|
||||||
|
if err != nil {
|
||||||
|
panic("invalid database configuration")
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err = run(ctx, db, *apply, *backup); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rows(ctx context.Context, tx *sql.Tx, query string) ([]map[string]any, error) {
|
||||||
|
r, err := tx.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
columns, err := r.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := []map[string]any{}
|
||||||
|
for r.Next() {
|
||||||
|
values := make([]any, len(columns))
|
||||||
|
targets := make([]any, len(columns))
|
||||||
|
for i := range values {
|
||||||
|
targets[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err = r.Scan(targets...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item := map[string]any{}
|
||||||
|
for i, k := range columns {
|
||||||
|
if b, ok := values[i].([]byte); ok {
|
||||||
|
item[k] = string(b)
|
||||||
|
} else {
|
||||||
|
item[k] = values[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, r.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type correction struct {
|
||||||
|
id, title, status string
|
||||||
|
remove bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, db *sql.DB, apply bool, backup string) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
archive := map[string]any{"createdAt": time.Now().UTC().Format(time.RFC3339), "reason": "decode GB32960 table 18; remove reserved-only false alarms"}
|
||||||
|
data := map[string][]map[string]any{}
|
||||||
|
// Consumer must be stopped for --apply. Lock event rows as well so a concurrent
|
||||||
|
// operator's action is serialized with the correction and fully backed up.
|
||||||
|
for _, q := range []struct{ key, sql string }{
|
||||||
|
{"events", `SELECT * FROM vehicle_alert_event WHERE rule_id='native-gb32960-alarm' ORDER BY id FOR UPDATE`},
|
||||||
|
{"evidence", `SELECT n.* FROM vehicle_native_alarm_evidence n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||||
|
{"actions", `SELECT a.* FROM vehicle_alert_event_action a JOIN vehicle_alert_event e ON e.id=a.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||||
|
{"state", `SELECT s.* FROM vehicle_native_alarm_state s JOIN vehicle_alert_event e ON e.id=s.active_event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||||
|
{"notifications", `SELECT n.id,n.event_id FROM vehicle_alert_notification n JOIN vehicle_alert_event e ON e.id=n.event_id WHERE e.rule_id='native-gb32960-alarm'`},
|
||||||
|
} {
|
||||||
|
items, e := rows(ctx, tx, q.sql)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
data[q.key] = items
|
||||||
|
archive[q.key] = items
|
||||||
|
}
|
||||||
|
evidence := map[string]string{}
|
||||||
|
for _, row := range data["evidence"] {
|
||||||
|
evidence[fmt.Sprint(row["event_id"])] = fmt.Sprint(row["fields_json"])
|
||||||
|
}
|
||||||
|
notified := map[string]bool{}
|
||||||
|
for _, row := range data["notifications"] {
|
||||||
|
notified[fmt.Sprint(row["event_id"])] = true
|
||||||
|
}
|
||||||
|
changes := []correction{}
|
||||||
|
renamed, removed := 0, 0
|
||||||
|
names := map[string]int{}
|
||||||
|
for _, row := range data["events"] {
|
||||||
|
id := fmt.Sprint(row["id"])
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err = json.Unmarshal([]byte(evidence[id]), &fields); err != nil {
|
||||||
|
return fmt.Errorf("event %s has no valid evidence; no changes applied", id)
|
||||||
|
}
|
||||||
|
description, valid := platform.DescribeNativeAlarm(fields)
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("event %s has unsupported evidence; no changes applied", id)
|
||||||
|
}
|
||||||
|
names[description.Title]++
|
||||||
|
if !description.Active {
|
||||||
|
if notified[id] {
|
||||||
|
return fmt.Errorf("event %s has notifications; refusing automatic deletion", id)
|
||||||
|
}
|
||||||
|
changes = append(changes, correction{id: id, remove: true})
|
||||||
|
removed++
|
||||||
|
} else if fmt.Sprint(row["rule_name"]) != description.Title {
|
||||||
|
changes = append(changes, correction{id: id, title: description.Title, status: fmt.Sprint(row["status"])})
|
||||||
|
renamed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apply && len(changes) > 0 {
|
||||||
|
file, e := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e = json.NewEncoder(file).Encode(archive)
|
||||||
|
if e == nil {
|
||||||
|
e = file.Sync()
|
||||||
|
}
|
||||||
|
closeErr := file.Close()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
for _, change := range changes {
|
||||||
|
if change.remove {
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET active_event_id='' WHERE active_event_id=?`, change.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, table := range []string{"vehicle_alert_event_action", "vehicle_native_alarm_evidence"} {
|
||||||
|
if _, err = tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE event_id=?", change.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_event WHERE id=? AND rule_id='native-gb32960-alarm'`, change.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET rule_name=?,version=version+1 WHERE id=? AND rule_id='native-gb32960-alarm'`, change.title, change.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'repair',?,?,'native-alarm-repair','按GB/T 32960.3-2016表18修正具体告警名称,保留原始证据')`, change.id, change.status, change.status); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.NewEncoder(os.Stdout).Encode(map[string]any{"applied": apply, "scanned": len(data["events"]), "renamed": renamed, "removed": removed, "descriptions": names})
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func expectSnapshot(mock sqlmock.Sqlmock, notified bool) {
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery(`SELECT \* FROM vehicle_alert_event`).WillReturnRows(sqlmock.NewRows([]string{"id", "rule_name", "status"}).AddRow("false-event", "车辆原生告警", "unprocessed").AddRow("real-event", "车辆原生告警", "processing"))
|
||||||
|
payload := func(flag string) string {
|
||||||
|
data := map[string]any{"gb32960.alarm.max_alarm_level": "0", "gb32960.alarm.general_alarm_flag": flag}
|
||||||
|
for _, key := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||||
|
data["gb32960.alarm."+key] = []string{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
mock.ExpectQuery(`SELECT n.\* FROM vehicle_native_alarm_evidence`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "fields_json"}).AddRow("false-event", payload("0x00300000")).AddRow("real-event", payload("0x00380800")))
|
||||||
|
mock.ExpectQuery(`SELECT a.\* FROM vehicle_alert_event_action`).WillReturnRows(sqlmock.NewRows([]string{"event_id", "action"}).AddRow("false-event", "trigger"))
|
||||||
|
mock.ExpectQuery(`SELECT s.\* FROM vehicle_native_alarm_state`).WillReturnRows(sqlmock.NewRows([]string{"vin", "active_event_id"}).AddRow("VIN1", "false-event"))
|
||||||
|
notifications := sqlmock.NewRows([]string{"id", "event_id"})
|
||||||
|
if notified {
|
||||||
|
notifications.AddRow(1, "false-event")
|
||||||
|
}
|
||||||
|
mock.ExpectQuery(`SELECT n.id,n.event_id`).WillReturnRows(notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairDryRunNeverWrites(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
expectSnapshot(mock, false)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
if err := run(t.Context(), db, false, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRepairBacksUpAndCorrectsOnlyNativeEvents(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
expectSnapshot(mock, false)
|
||||||
|
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(`DELETE FROM vehicle_alert_event_action`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(`DELETE FROM vehicle_native_alarm_evidence`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(`DELETE FROM vehicle_alert_event WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("false-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(`UPDATE vehicle_alert_event SET rule_name=\?,version=version\+1 WHERE id=\? AND rule_id='native-gb32960-alarm'`).WithArgs("绝缘报警", "real-event").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WithArgs("real-event", "processing", "processing").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
path := filepath.Join(t.TempDir(), "backup.json")
|
||||||
|
if err := run(t.Context(), db, true, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var archive map[string]json.RawMessage
|
||||||
|
if err = json.Unmarshal(data, &archive); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"events", "evidence", "actions", "state"} {
|
||||||
|
if len(archive[key]) == 0 {
|
||||||
|
t.Fatalf("missing backup %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(path)
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatal("backup permissions")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRepairRefusesToOverwriteBackup(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
expectSnapshot(mock, false)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
path := filepath.Join(t.TempDir(), "backup.json")
|
||||||
|
if err := os.WriteFile(path, []byte("previous backup"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := run(t.Context(), db, true, path); err == nil {
|
||||||
|
t.Fatal("overwrote backup")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRepairRefusesToOrphanNotifications(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
expectSnapshot(mock, true)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
if err := run(t.Context(), db, true, filepath.Join(t.TempDir(), "backup.json")); err == nil {
|
||||||
|
t.Fatal("deleted notified event")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/dailygeo"
|
||||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||||
@@ -69,6 +70,11 @@ func NewServer(cfg config.Config) http.Handler {
|
|||||||
log.Printf("production capacity-check probe enabled")
|
log.Printf("production capacity-check probe enabled")
|
||||||
}
|
}
|
||||||
productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
|
productionStore.WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
|
||||||
|
if cfg.DailyGeographyEnabled && tdengine != nil && cfg.AMapAPIKey != "" {
|
||||||
|
geographyWorker := dailygeo.New(db, tdengine, cfg.TDengineDatabase, &dailygeo.AMap{Key: cfg.AMapAPIKey})
|
||||||
|
geographyWorker.RequestInterval = 100 * time.Millisecond
|
||||||
|
go geographyWorker.Run(context.Background())
|
||||||
|
}
|
||||||
store = productionStore
|
store = productionStore
|
||||||
storeErr = nil
|
storeErr = nil
|
||||||
log.Printf("production mysql store enabled")
|
log.Printf("production mysql store enabled")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
DailyGeographyEnabled bool
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
StaticDir string
|
StaticDir string
|
||||||
MySQLDSN string
|
MySQLDSN string
|
||||||
@@ -97,6 +98,7 @@ func Load() Config {
|
|||||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||||
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
||||||
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
||||||
|
DailyGeographyEnabled: envBool("DAILY_GEOGRAPHY_ENABLED", false),
|
||||||
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
||||||
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
||||||
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package dailygeo
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
func WGS84ToGCJ02(longitude float64, latitude float64) (float64, float64) {
|
||||||
|
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
|
||||||
|
return longitude, latitude
|
||||||
|
}
|
||||||
|
const semiMajorAxis = 6378245.0
|
||||||
|
const eccentricitySquared = 0.006693421622965943
|
||||||
|
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
|
||||||
|
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
|
||||||
|
radianLatitude := latitude / 180 * math.Pi
|
||||||
|
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
|
||||||
|
squareRootMagic := math.Sqrt(magic)
|
||||||
|
convertedLatitude := latitude + latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
|
||||||
|
convertedLongitude := longitude + longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
|
||||||
|
return convertedLongitude, convertedLatitude
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformGCJLatitude(longitude float64, latitude float64) float64 {
|
||||||
|
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
|
||||||
|
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||||
|
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
|
||||||
|
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func transformGCJLongitude(longitude float64, latitude float64) float64 {
|
||||||
|
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
|
||||||
|
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||||
|
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
|
||||||
|
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package dailygeo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Address struct{ Province, City, Region, Adcode string }
|
||||||
|
type Resolver interface {
|
||||||
|
Resolve(context.Context, float64, float64) (Address, error)
|
||||||
|
}
|
||||||
|
type AMap struct {
|
||||||
|
Key, BaseURL string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AMap) Resolve(ctx context.Context, lng, lat float64) (Address, error) {
|
||||||
|
if a.Key == "" {
|
||||||
|
return Address{}, fmt.Errorf("AMAP_API_KEY is not configured")
|
||||||
|
}
|
||||||
|
base := a.BaseURL
|
||||||
|
if base == "" {
|
||||||
|
base = "https://restapi.amap.com"
|
||||||
|
}
|
||||||
|
u, e := url.Parse(base + "/v3/geocode/regeo")
|
||||||
|
if e != nil {
|
||||||
|
return Address{}, fmt.Errorf("invalid geocoder URL")
|
||||||
|
}
|
||||||
|
x, y := WGS84ToGCJ02(lng, lat)
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("key", a.Key)
|
||||||
|
q.Set("location", fmt.Sprintf("%.6f,%.6f", x, y))
|
||||||
|
q.Set("extensions", "base")
|
||||||
|
q.Set("radius", "1000")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if e != nil {
|
||||||
|
return Address{}, fmt.Errorf("invalid geocoder request")
|
||||||
|
}
|
||||||
|
client := a.Client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
resp, e := client.Do(req)
|
||||||
|
if e != nil {
|
||||||
|
return Address{}, fmt.Errorf("geocoder request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return Address{}, fmt.Errorf("geocoder HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Status string
|
||||||
|
Infocode string
|
||||||
|
Regeocode struct {
|
||||||
|
AddressComponent struct{ Province, City, District, Adcode json.RawMessage } `json:"addressComponent"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); e != nil {
|
||||||
|
return Address{}, fmt.Errorf("invalid geocoder response")
|
||||||
|
}
|
||||||
|
if body.Status != "1" {
|
||||||
|
return Address{}, fmt.Errorf("geocoder rejected: %s", body.Infocode)
|
||||||
|
}
|
||||||
|
p, c := textValue(body.Regeocode.AddressComponent.Province), textValue(body.Regeocode.AddressComponent.City)
|
||||||
|
if c == "" {
|
||||||
|
switch p {
|
||||||
|
case "北京市", "上海市", "天津市", "重庆市", "香港特别行政区", "澳门特别行政区":
|
||||||
|
c = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Province-administered county-level cities (for example 潜江市) are
|
||||||
|
// returned in district, with city=[], by AMap.
|
||||||
|
if c == "" && p != "" {
|
||||||
|
district := textValue(body.Regeocode.AddressComponent.District)
|
||||||
|
if strings.HasSuffix(district, "市") {
|
||||||
|
c = district
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p == "" || c == "" {
|
||||||
|
return Address{}, fmt.Errorf("geocoder returned incomplete province/city")
|
||||||
|
}
|
||||||
|
return Address{p, c, Region(p), textValue(body.Regeocode.AddressComponent.Adcode)}, nil
|
||||||
|
}
|
||||||
|
func textValue(raw json.RawMessage) string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var ss []string
|
||||||
|
if json.Unmarshal(raw, &ss) == nil {
|
||||||
|
return strings.Join(ss, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func Region(province string) string {
|
||||||
|
groups := []struct {
|
||||||
|
name string
|
||||||
|
provinces []string
|
||||||
|
}{
|
||||||
|
{"华东", []string{"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"}},
|
||||||
|
{"华北", []string{"北京", "天津", "河北", "山西", "内蒙古"}},
|
||||||
|
{"华中", []string{"河南", "湖北", "湖南"}},
|
||||||
|
{"华南", []string{"广东", "广西", "海南", "香港", "澳门"}},
|
||||||
|
{"东北", []string{"辽宁", "吉林", "黑龙江"}},
|
||||||
|
{"西南", []string{"重庆", "四川", "贵州", "云南", "西藏"}},
|
||||||
|
{"西北", []string{"陕西", "甘肃", "青海", "宁夏", "新疆"}},
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, p := range g.provinces {
|
||||||
|
if strings.HasPrefix(province, p) {
|
||||||
|
return g.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package dailygeo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Shanghai = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
type Point struct {
|
||||||
|
VIN, Protocol string
|
||||||
|
Time time.Time
|
||||||
|
Longitude, Latitude float64
|
||||||
|
}
|
||||||
|
type Worker struct {
|
||||||
|
RequestInterval time.Duration
|
||||||
|
DB, TD *sql.DB
|
||||||
|
Database string
|
||||||
|
Resolver Resolver
|
||||||
|
Concurrency int
|
||||||
|
requestMu sync.Mutex
|
||||||
|
nextRequest time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db, td *sql.DB, database string, r Resolver) *Worker {
|
||||||
|
return &Worker{DB: db, TD: td, Database: database, Resolver: r, Concurrency: 3}
|
||||||
|
}
|
||||||
|
func validPoint(p Point) bool {
|
||||||
|
return p.VIN != "" && !(p.Longitude == -0.999999 && p.Latitude == -0.999999) && !p.Time.IsZero() && p.Longitude != 0 && p.Latitude != 0 && !math.IsNaN(p.Longitude) && !math.IsNaN(p.Latitude) && math.Abs(p.Longitude) <= 180 && math.Abs(p.Latitude) <= 90
|
||||||
|
}
|
||||||
|
func newer(a, b Point) bool {
|
||||||
|
return a.Time.After(b.Time) || (a.Time.Equal(b.Time) && a.Protocol < b.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last() is applied only to non-null valid coordinates, partitioned by VIN and
|
||||||
|
// protocol. All selected columns therefore refer to the same final source row.
|
||||||
|
func (w *Worker) RefreshDay(ctx context.Context, date string) (int, error) {
|
||||||
|
day, e := time.ParseInLocation("2006-01-02", date, Shanghai)
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
if w.TD == nil || !identifier.MatchString(w.Database) {
|
||||||
|
return 0, fmt.Errorf("historical location store unavailable")
|
||||||
|
}
|
||||||
|
q := fmt.Sprintf(`SELECT vin,protocol,LAST(ts),LAST(longitude),LAST(latitude) FROM %s.vehicle_locations WHERE ts >= '%s' AND ts < '%s' AND NOT (longitude = -0.999999 AND latitude = -0.999999) AND longitude <> 0 AND longitude BETWEEN -180 AND 180 AND latitude <> 0 AND latitude BETWEEN -90 AND 90 PARTITION BY vin,protocol`, w.Database, day.Format(time.RFC3339), day.AddDate(0, 0, 1).Format(time.RFC3339))
|
||||||
|
rows, e := w.TD.QueryContext(ctx, q)
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
points := map[string]Point{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Point
|
||||||
|
if e = rows.Scan(&p.VIN, &p.Protocol, &p.Time, &p.Longitude, &p.Latitude); e != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
if !validPoint(p) || p.Time.In(Shanghai).Format("2006-01-02") != date {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if old, ok := points[p.VIN]; !ok || newer(p, old) {
|
||||||
|
points[p.VIN] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
// Repair the device's invalid-location sentinel before applying real points.
|
||||||
|
// Clearing its timestamp allows an earlier valid point from this day to win.
|
||||||
|
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET longitude=NULL,latitude=NULL,location_time=NULL,province='',city='',region='',adcode='',status='NO_LOCATION',attempts=0,last_error='',next_attempt_at=NULL,resolved_at=NULL WHERE stat_date=? AND longitude=-0.999999 AND latitude=-0.999999`, date)
|
||||||
|
if e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
for _, p := range points {
|
||||||
|
if e = w.SavePoint(ctx, p); e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A successful historical lookup with no point is distinct from an unavailable
|
||||||
|
// history service. Never record NO_LOCATION when the lookup itself failed.
|
||||||
|
_, e = w.DB.ExecContext(ctx, `INSERT IGNORE INTO vehicle_daily_geography(vin,stat_date,status) SELECT vin,stat_date,'NO_LOCATION' FROM vehicle_open_daily_energy WHERE energy_type='HYDROGEN' AND stat_date=?`, date)
|
||||||
|
return len(points), e
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePointSQL = `INSERT INTO vehicle_daily_geography(vin,stat_date,longitude,latitude,location_time,source_protocol,status) VALUES(?,?,?,?,?,?,'PENDING') ON DUPLICATE KEY UPDATE
|
||||||
|
province=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',province),
|
||||||
|
city=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',city),
|
||||||
|
region=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',region),
|
||||||
|
adcode=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',adcode),
|
||||||
|
status=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'PENDING',status),
|
||||||
|
attempts=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),0,attempts),
|
||||||
|
next_attempt_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,next_attempt_at),
|
||||||
|
last_error=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),'',last_error),
|
||||||
|
resolved_at=IF((location_time IS NULL OR VALUES(location_time)>location_time) AND (longitude IS NULL OR longitude<>VALUES(longitude) OR latitude<>VALUES(latitude)),NULL,resolved_at),
|
||||||
|
longitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(longitude),longitude),
|
||||||
|
latitude=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(latitude),latitude),
|
||||||
|
source_protocol=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(source_protocol),source_protocol),
|
||||||
|
location_time=IF(location_time IS NULL OR VALUES(location_time)>location_time,VALUES(location_time),location_time)`
|
||||||
|
|
||||||
|
func (w *Worker) SavePoint(ctx context.Context, p Point) error {
|
||||||
|
if !validPoint(p) {
|
||||||
|
return fmt.Errorf("invalid historical location")
|
||||||
|
}
|
||||||
|
_, e := w.DB.ExecContext(ctx, savePointSQL, p.VIN, p.Time.In(Shanghai).Format("2006-01-02"), math.Round(p.Longitude*1e6)/1e6, math.Round(p.Latitude*1e6)/1e6, p.Time.In(Shanghai), p.Protocol)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
type pending struct {
|
||||||
|
VIN, Date string
|
||||||
|
Longitude, Latitude float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) ResolvePending(ctx context.Context, from, to string, limit int) (int, int, error) {
|
||||||
|
if limit <= 0 || limit > 5000 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
rows, e := w.DB.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),longitude,latitude FROM vehicle_daily_geography WHERE stat_date BETWEEN ? AND ? AND status IN ('PENDING','ERROR') AND (next_attempt_at IS NULL OR next_attempt_at<=NOW(3)) ORDER BY stat_date DESC,vin LIMIT ?`, from, to, limit)
|
||||||
|
if e != nil {
|
||||||
|
return 0, 0, e
|
||||||
|
}
|
||||||
|
items := []pending{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p pending
|
||||||
|
if e = rows.Scan(&p.VIN, &p.Date, &p.Longitude, &p.Latitude); e != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, 0, e
|
||||||
|
}
|
||||||
|
items = append(items, p)
|
||||||
|
}
|
||||||
|
e = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if e != nil {
|
||||||
|
return 0, 0, e
|
||||||
|
}
|
||||||
|
jobs := make(chan pending, len(items))
|
||||||
|
for _, p := range items {
|
||||||
|
jobs <- p
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
count := w.Concurrency
|
||||||
|
if count < 1 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
if count > 8 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
ok, failed := 0, 0
|
||||||
|
var first error
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for p := range jobs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e := w.resolveOne(ctx, p)
|
||||||
|
mu.Lock()
|
||||||
|
if e == nil {
|
||||||
|
ok++
|
||||||
|
} else {
|
||||||
|
failed++
|
||||||
|
if first == nil {
|
||||||
|
first = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ok, failed, ctx.Err()
|
||||||
|
}
|
||||||
|
return ok, failed, first
|
||||||
|
}
|
||||||
|
func (w *Worker) resolveOne(ctx context.Context, p pending) error {
|
||||||
|
var a Address
|
||||||
|
e := w.DB.QueryRowContext(ctx, `SELECT province,city,region,adcode FROM vehicle_geography_cache WHERE longitude=? AND latitude=? AND resolved_at>=DATE_SUB(NOW(),INTERVAL 180 DAY)`, p.Longitude, p.Latitude).Scan(&a.Province, &a.City, &a.Region, &a.Adcode)
|
||||||
|
if e != nil && e != sql.ErrNoRows {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e == sql.ErrNoRows {
|
||||||
|
// At most three external calls in flight by default; quota/network failures
|
||||||
|
// are persisted and retried with backoff, never exported as a guessed city.
|
||||||
|
w.requestMu.Lock()
|
||||||
|
delay := time.Until(w.nextRequest)
|
||||||
|
if delay > 0 {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
w.requestMu.Unlock()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interval := w.RequestInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 20 * time.Millisecond
|
||||||
|
}
|
||||||
|
w.nextRequest = time.Now().Add(interval)
|
||||||
|
w.requestMu.Unlock()
|
||||||
|
rctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||||
|
a, e = w.Resolver.Resolve(rctx, p.Longitude, p.Latitude)
|
||||||
|
cancel()
|
||||||
|
if e != nil {
|
||||||
|
_, saveErr := w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET status='ERROR',attempts=attempts+1,last_error=?,next_attempt_at=DATE_ADD(NOW(3),INTERVAL LEAST(3600,60*POW(2,LEAST(attempts,6))) SECOND) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, e.Error(), p.VIN, p.Date, p.Longitude, p.Latitude)
|
||||||
|
if saveErr != nil {
|
||||||
|
return saveErr
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
_, e = w.DB.ExecContext(ctx, `INSERT INTO vehicle_geography_cache(longitude,latitude,province,city,region,adcode) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE province=VALUES(province),city=VALUES(city),region=VALUES(region),adcode=VALUES(adcode),resolved_at=NOW(3)`, p.Longitude, p.Latitude, a.Province, a.City, a.Region, a.Adcode)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, e = w.DB.ExecContext(ctx, `UPDATE vehicle_daily_geography SET province=?,city=?,region=?,adcode=?,status='RESOLVED',last_error='',attempts=0,next_attempt_at=NULL,resolved_at=NOW(3) WHERE vin=? AND stat_date=? AND longitude=? AND latitude=?`, a.Province, a.City, a.Region, a.Adcode, p.VIN, p.Date, p.Longitude, p.Latitude)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// An advisory lock is tied to this dedicated connection and released even on
|
||||||
|
// cancellation/crash, preventing multiple API replicas from running the loop.
|
||||||
|
func (w *Worker) WithLock(ctx context.Context, name string, fn func() error) error {
|
||||||
|
conn, e := w.DB.Conn(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
var acquired int
|
||||||
|
if e = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, name).Scan(&acquired); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if acquired != 1 {
|
||||||
|
return fmt.Errorf("daily geography worker already running")
|
||||||
|
}
|
||||||
|
defer conn.ExecContext(context.Background(), `SELECT RELEASE_LOCK(?)`, name)
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
round := 0
|
||||||
|
for {
|
||||||
|
e := w.WithLock(ctx, "vehicle_daily_geography_live", func() error {
|
||||||
|
today := time.Now().In(Shanghai)
|
||||||
|
date := today.Format("2006-01-02")
|
||||||
|
rctx, c := context.WithTimeout(ctx, 50*time.Second)
|
||||||
|
defer c()
|
||||||
|
if _, e := w.RefreshDay(rctx, date); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if round%60 == 0 {
|
||||||
|
for d := 1; d <= 2; d++ {
|
||||||
|
if _, e := w.RefreshDay(rctx, today.AddDate(0, 0, -d).Format("2006-01-02")); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, failed, e := w.ResolvePending(rctx, today.AddDate(0, 0, -2).Format("2006-01-02"), date, 500)
|
||||||
|
log.Printf("daily_geography resolved=%d failed=%d", n, failed)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Historical retry is independent of the live queue. The same lock used
|
||||||
|
// by the CLI prevents duplicate external calls during a bulk backfill.
|
||||||
|
_ = w.WithLock(rctx, "vehicle_daily_geography_backfill", func() error {
|
||||||
|
n, failed, e := w.ResolvePending(rctx, "2000-01-01", today.AddDate(0, 0, -3).Format("2006-01-02"), 200)
|
||||||
|
if n+failed > 0 {
|
||||||
|
log.Printf("daily_geography_history_retry resolved=%d failed=%d error=%v", n, failed, e)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if e != nil {
|
||||||
|
log.Printf("daily_geography: %v", e)
|
||||||
|
}
|
||||||
|
round++
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package dailygeo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolverFunc func(context.Context, float64, float64) (Address, error)
|
||||||
|
|
||||||
|
func (f resolverFunc) Resolve(c context.Context, x, y float64) (Address, error) { return f(c, x, y) }
|
||||||
|
func TestAMapMunicipalityAndRegion(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("key") != "test-key" {
|
||||||
|
t.Error("missing credential")
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"adcode":"310101"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
a, e := (&AMap{Key: "test-key", BaseURL: server.URL}).Resolve(context.Background(), 121.47, 31.23)
|
||||||
|
if e != nil || a.Province != "上海市" || a.City != "上海市" || a.Region != "华东" {
|
||||||
|
t.Fatalf("address=%+v error=%v", a, e)
|
||||||
|
}
|
||||||
|
for p, want := range map[string]string{"广东省": "华南", "四川省": "西南", "陕西省": "西北", "山西省": "华北", "湖北省": "华中", "吉林省": "东北", "": ""} {
|
||||||
|
if Region(p) != want {
|
||||||
|
t.Fatal(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestAMapQuotaFailureDoesNotLeakKey(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"status":"0","infocode":"10021"}`)) }))
|
||||||
|
defer server.Close()
|
||||||
|
_, e := (&AMap{Key: "secret-value", BaseURL: server.URL}).Resolve(context.Background(), 121, 31)
|
||||||
|
if e == nil || !strings.Contains(e.Error(), "10021") || strings.Contains(e.Error(), "secret-value") {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestCachedCoordinateNeedsNoGeocoding(t *testing.T) {
|
||||||
|
db, m, e := sqlmock.New()
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
m.ExpectQuery("SELECT province,city,region,adcode").WithArgs(121.0, 31.0).WillReturnRows(sqlmock.NewRows([]string{"province", "city", "region", "adcode"}).AddRow("上海市", "上海市", "华东", "310000"))
|
||||||
|
m.ExpectExec("UPDATE vehicle_daily_geography SET province=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("上海市", "上海市", "华东", "310000", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) {
|
||||||
|
t.Fatal("cache hit must not call external API")
|
||||||
|
return Address{}, nil
|
||||||
|
}))
|
||||||
|
if e = w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = m.ExpectationsWereMet(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestResolverFailurePersistsRetryInsteadOfFalseUnknown(t *testing.T) {
|
||||||
|
db, m, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
m.ExpectQuery("SELECT province,city,region,adcode").WillReturnError(sql.ErrNoRows)
|
||||||
|
m.ExpectExec("UPDATE vehicle_daily_geography SET status='ERROR'.*next_attempt_at=.*WHERE vin=\\? AND stat_date=\\? AND longitude=\\? AND latitude=\\?").WithArgs("quota", "VIN1", "2026-08-31", 121.0, 31.0).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
w := New(db, nil, "", resolverFunc(func(context.Context, float64, float64) (Address, error) { return Address{}, errors.New("quota") }))
|
||||||
|
if e := w.resolveOne(context.Background(), pending{"VIN1", "2026-08-31", 121, 31}); e == nil {
|
||||||
|
t.Fatal("expected failure")
|
||||||
|
}
|
||||||
|
if e := m.ExpectationsWereMet(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalFailureDoesNotMarkMissingLocation(t *testing.T) {
|
||||||
|
db, m, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tm, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
tm.ExpectQuery("SELECT vin,protocol,LAST").WillReturnError(errors.New("historical store unavailable"))
|
||||||
|
w := New(db, td, "test_ts", nil)
|
||||||
|
if _, e := w.RefreshDay(context.Background(), "2026-08-31"); e == nil {
|
||||||
|
t.Fatal("expected history error")
|
||||||
|
}
|
||||||
|
if e := m.ExpectationsWereMet(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e := tm.ExpectationsWereMet(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoryUsesLatestSourceInsideBusinessDate(t *testing.T) {
|
||||||
|
db, m, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tm, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
early := time.Date(2026, 8, 31, 2, 0, 0, 0, Shanghai)
|
||||||
|
late := early.Add(20 * time.Hour)
|
||||||
|
tm.ExpectQuery("SELECT vin,protocol,LAST.*2026-08-31T00:00:00\\+08:00.*2026-09-01T00:00:00\\+08:00.*PARTITION BY vin,protocol").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "ts", "longitude", "latitude"}).AddRow("VIN1", "GB32960", late, 121.0, 31.0).AddRow("VIN1", "JT808", early, 113.0, 23.0).AddRow("VIN2", "JT808", late.Add(5*time.Hour), 110.0, 20.0))
|
||||||
|
m.ExpectExec("UPDATE vehicle_daily_geography SET longitude=NULL").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
m.ExpectExec(regexp.QuoteMeta(savePointSQL)).WithArgs("VIN1", "2026-08-31", 121.0, 31.0, late, "GB32960").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
m.ExpectExec("INSERT IGNORE INTO vehicle_daily_geography").WithArgs("2026-08-31").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
w := New(db, td, "test_ts", nil)
|
||||||
|
n, e := w.RefreshDay(context.Background(), "2026-08-31")
|
||||||
|
if e != nil || n != 1 {
|
||||||
|
t.Fatalf("n=%d error=%v", n, e)
|
||||||
|
}
|
||||||
|
if e = m.ExpectationsWereMet(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e = tm.ExpectationsWereMet(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvinceAdministeredCity(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"status":"1","regeocode":{"addressComponent":{"province":"湖北省","city":[],"district":"潜江市","adcode":"429005"}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
a, e := (&AMap{Key: "test", BaseURL: server.URL}).Resolve(context.Background(), 112.807848, 30.381828)
|
||||||
|
if e != nil || a.City != "潜江市" || a.Region != "华中" {
|
||||||
|
t.Fatalf("address=%+v error=%v", a, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidLocationSentinel(t *testing.T) {
|
||||||
|
p := Point{VIN: "VIN1", Time: time.Now(), Longitude: -0.999999, Latitude: -0.999999}
|
||||||
|
if validPoint(p) {
|
||||||
|
t.Fatal("invalid device sentinel accepted")
|
||||||
|
}
|
||||||
|
p.Longitude, p.Latitude = 114.3, 30.5
|
||||||
|
if !validPoint(p) {
|
||||||
|
t.Fatal("valid location rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: 车辆数据开放平台 API
|
title: 车辆数据开放平台 API
|
||||||
version: 1.7.0
|
version: 1.10.0
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
description: |
|
description: |
|
||||||
@@ -16,6 +16,55 @@ tags:
|
|||||||
- name: 开放平台管理
|
- name: 开放平台管理
|
||||||
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
||||||
paths:
|
paths:
|
||||||
|
/api/v1/vehicles/hydrogen-remaining/history/query:
|
||||||
|
post:
|
||||||
|
tags: [合作方数据接口]
|
||||||
|
summary: 批量查询历史时刻剩余氢质量
|
||||||
|
description: |
|
||||||
|
按真实 event_time 查询不晚于指定时刻的最近氢相关帧,不跳过最新异常记录,不使用实时值或日用氢量填补。
|
||||||
|
历史支持从 2026-08-01 起;实际车辆/时间覆盖以保留的原始帧为准,日期可请求不代表每个时点均有有效氢量。
|
||||||
|
历史容量必须具有适用于历史时点的证据;不能用当前配置回套历史。当前缺少历史容量版本,只输出有证据的REPORTED质量,温压有效但无容量版本时 MISSING。
|
||||||
|
同批按规范化VIN+time+protocol去重,同一点共享一次查询/授权结果与来源版本,各requestId仍独立回显;不同点为非原子快照,不以同批证明两端可比。
|
||||||
|
200项仅为结构上限,不保证200个不同点能在9秒完成。建议先用20个不同点,ERROR超时时缩小批量并退避重试。
|
||||||
|
有效采样超容差为STALE,最新帧INVALID/MISSING优先保留原原因;分块原始帧重组失败或候选预算耗尽为ERROR,不伪装NO_DATA。
|
||||||
|
每项均返回 requestId;FORBIDDEN、ERROR 等失败不丢项,非 NORMAL 质量为 null。
|
||||||
|
appKey须当前有效,并逐项校验请求时刻与采样时刻授权(结束时间不含端点);FORBIDDEN不返回采样元数据。
|
||||||
|
每app每服务实例30批/60秒,最多2个并发批;429包含Retry-After。整批9秒预算、最多4个工作协程,超时项ERROR,可重试。
|
||||||
|
operationId: queryHistoricalHydrogenRemaining
|
||||||
|
security:
|
||||||
|
- AppKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HistoricalHydrogenQuery'
|
||||||
|
example:
|
||||||
|
queries:
|
||||||
|
- requestId: job-start
|
||||||
|
vin: LA9GG68L2PBAF4773
|
||||||
|
time: '2026-08-03 16:02:43'
|
||||||
|
protocol: GB32960
|
||||||
|
maxTimeDifferenceSeconds: 300
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 批量处理完成;每项状态独立判断,包括 FORBIDDEN 或 ERROR
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HistoricalHydrogenQueryResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'429':
|
||||||
|
description: 超出历史查询限流;读取 Retry-After 秒数后重试
|
||||||
|
headers:
|
||||||
|
Retry-After:
|
||||||
|
schema: { type: integer, minimum: 1 }
|
||||||
|
description: 建议等待秒数
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
/api/v1/vehicles/hydrogen-consumption/query:
|
/api/v1/vehicles/hydrogen-consumption/query:
|
||||||
post:
|
post:
|
||||||
tags: [合作方数据接口]
|
tags: [合作方数据接口]
|
||||||
@@ -53,9 +102,9 @@ paths:
|
|||||||
summary: 查询车辆单日里程
|
summary: 查询车辆单日里程
|
||||||
description: |
|
description: |
|
||||||
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||||
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
protocolPriority 传入时,逐车按数组顺序选择截至查询日已有有效累计读数的第一个协议;高优先级协议缺报时沿用其历史累计值,不切换累计基准,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||||
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程可由 GPS 轨迹估算;累计总里程读取每日统计中的 day_end_total_mileage_km,始终优先采用同协议终端上报的累计里程,不会使用 GPS 日里程估算值冒充累计里程。
|
NORMAL 结果同时包含日里程、日末累计总里程、实际来源协议、源数据时间和投影更新时间。日里程按同来源相邻自然日累计读数之差计算,缺报期间增量计入恢复上报日;GPS 估算不参与本接口。首次基线、来源切换、累计回退返回 DATA_ANOMALY、dailyMileageKm=null 和 dataQuality。
|
||||||
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
|
当日缺报时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用同协议历史读数,dataQuality=CARRIED_FORWARD。
|
||||||
operationId: queryDailyMileage
|
operationId: queryDailyMileage
|
||||||
security:
|
security:
|
||||||
- AppKeyAuth: []
|
- AppKeyAuth: []
|
||||||
@@ -92,7 +141,7 @@ paths:
|
|||||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK,绝不沿用历史值伪装为正常数据。日里程与日末累计总里程独立统计:GPS 轨迹估算仅用于日里程,累计总里程读取每日统计字段 day_end_total_mileage_km。
|
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。若终端累计里程回退,返回 DATA_ANOMALY 与 dataQuality=TOTAL_MILEAGE_ROLLBACK。日里程与累计值使用同一终端来源;日里程为相邻自然日累计差,GPS 估算不参与本接口。单日、区间和分页共用相同计算规则。
|
||||||
operationId: queryDailyMileageRange
|
operationId: queryDailyMileageRange
|
||||||
security:
|
security:
|
||||||
- AppKeyAuth: []
|
- AppKeyAuth: []
|
||||||
@@ -210,7 +259,12 @@ paths:
|
|||||||
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
|
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
|
||||||
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
|
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
|
||||||
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
|
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
|
||||||
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。
|
||||||
|
满充容量采用 PressureHydrogenMassKg(35,15,VIN水容积);35 MPa 是本次业务基准,15°C 密度比参考来自 https://unece.org/sites/default/files/2024-07/ECE_TRANS_WP.29_2023_110E.pdf,不代表车型额定压力自动认证。
|
||||||
|
储氢优先终端上报质量 REPORTED;无质量时以同帧最大氢压/最大氢温与 VIN 全车水容积估算 ESTIMATED。百分比按 35 MPa、15°C 满充参考质量计算,不以 SOC 替代。
|
||||||
|
仅 GB 最新快照有引用且精确帧氢量 MISSING 时有界回补,按采集时间优先、接收时间次序检查最近5条候选;不完整温压可取次新完整帧,不回退覆盖 INVALID,离线回补保留真实 STALE。
|
||||||
|
GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。
|
||||||
|
补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。
|
||||||
operationId: queryRealtimeVehicles
|
operationId: queryRealtimeVehicles
|
||||||
security:
|
security:
|
||||||
- AppKeyAuth: []
|
- AppKeyAuth: []
|
||||||
@@ -524,7 +578,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
vin:
|
vin:
|
||||||
type: string
|
type: string
|
||||||
pattern: '^[A-HJ-NPR-Z0-9]{17}$'
|
pattern: '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$'
|
||||||
description: 已授权车辆 VIN
|
description: 已授权车辆 VIN
|
||||||
time:
|
time:
|
||||||
type: string
|
type: string
|
||||||
@@ -593,6 +647,67 @@ components:
|
|||||||
minLength: 1
|
minLength: 1
|
||||||
maxLength: 32
|
maxLength: 32
|
||||||
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
||||||
|
HistoricalHydrogenQuery:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [queries]
|
||||||
|
properties:
|
||||||
|
queries:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 200
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [requestId, vin, time]
|
||||||
|
properties:
|
||||||
|
requestId: { type: string, minLength: 1, maxLength: 128, description: 本批唯一非空关联标识,最多128字节,原样返回 }
|
||||||
|
vin: { type: string, minLength: 17, maxLength: 17, pattern: '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$', description: 查询VIN按大写规范化,禁止I/O/Q }
|
||||||
|
time: { type: string, description: '北京时间 yyyy-MM-dd HH:mm:ss;不得早于2026-08-01或晚于请求时刻' }
|
||||||
|
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808], default: GB32960, description: 省略固定GB32960;显式协议不切换,YUTONG_MQTT/JT808当前UNSUPPORTED }
|
||||||
|
maxTimeDifferenceSeconds:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 300
|
||||||
|
default: 300
|
||||||
|
description: 允许采样早于查询的最大秒数;0要求精确命中,不静默放宽
|
||||||
|
HistoricalHydrogenQueryResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||||
|
- type: object
|
||||||
|
required: [data]
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/HistoricalHydrogenResult' }
|
||||||
|
HistoricalHydrogenResult:
|
||||||
|
type: object
|
||||||
|
required: [requestId, vin, plateNumber, queryTime, remainingHydrogenKg, hydrogenRecordTime, timeDifferenceSeconds, remainingHydrogenKgStatus, hydrogenValueSource, hydrogenSourceProtocol, sourceRecordId, sourceDataVersion, hydrogenEstimatePressureMPa, hydrogenEstimateTemperatureC, hydrogenTankCapacityL, hydrogenPressureTemperatureSource, hydrogenCalculationVersion, hydrogenCapacityVersion, updatedAt, reasonCode, message]
|
||||||
|
properties:
|
||||||
|
requestId: { type: string, description: 本批对应请求标识 }
|
||||||
|
vin: { type: string }
|
||||||
|
plateNumber: { type: string, nullable: true, description: 无可证明历史车牌时为 null,不冒用当前车牌 }
|
||||||
|
queryTime: { type: string, description: 请求北京时间 }
|
||||||
|
remainingHydrogenKg: { type: number, nullable: true, minimum: 0, description: '全车剩余氢质量kg,保留源/模型计算精度;仅NORMAL可用,其他状态null。不得用缺值补0' }
|
||||||
|
hydrogenRecordTime: { type: string, format: date-time, nullable: true, description: 真实原始帧 event_time,RFC3339带时区 }
|
||||||
|
timeDifferenceSeconds: { type: number, nullable: true, minimum: 0, description: queryTime减真实采集时间的秒数 }
|
||||||
|
remainingHydrogenKgStatus:
|
||||||
|
type: string
|
||||||
|
enum: [NORMAL, NO_DATA, MISSING, STALE, UNSUPPORTED, INVALID, FORBIDDEN, ERROR]
|
||||||
|
description: 仅NORMAL参与业务计算;ERROR明确为处理失败,可按原因重试;FORBIDDEN不泄露采样
|
||||||
|
hydrogenValueSource: { type: string, nullable: true, enum: [REPORTED, ESTIMATED], description: 终端上报不等同于直接测量;估算需历史温压/容积证据 }
|
||||||
|
hydrogenSourceProtocol: { type: string, nullable: true, enum: [GB32960, YUTONG_MQTT, JT808] }
|
||||||
|
sourceRecordId: { type: string, nullable: true, description: raw-sha256采样身份指纹;两端相同需识别采样分辨率不足,内容修订另看sourceDataVersion }
|
||||||
|
sourceDataVersion: { type: string, nullable: true, description: 'sha256原始JSON和解析状态内容指纹;同采样被修订时变化,不是两端必须相等的口径版本' }
|
||||||
|
hydrogenEstimatePressureMPa: { type: number, nullable: true, description: 有效同帧最大氢压MPa证据,缺历史容量仍可诊断返回 }
|
||||||
|
hydrogenEstimateTemperatureC: { type: number, nullable: true, description: 有效同帧最大氢温摄氏度证据 }
|
||||||
|
hydrogenTankCapacityL: { type: number, nullable: true, description: 当前缺历史容量版本始终null,不能回套当前容积 }
|
||||||
|
hydrogenPressureTemperatureSource: { type: string, nullable: true, enum: [MAX_SENSOR_AGGREGATE], description: 最大温压聚合不保证同瓶 }
|
||||||
|
hydrogenCalculationVersion: { type: string, nullable: true, description: 当前REPORTED_HYDROGEN_KG_V1,质量不额外舍入;ESTIMATED预留但当前缺历史容积版本不输出估算值 }
|
||||||
|
hydrogenCapacityVersion: { type: string, nullable: true, description: 历史时点适用容积版本;估算必需,无证据不估算 }
|
||||||
|
updatedAt: { type: string, format: date-time, nullable: true, description: 原始记录received_at,RFC3339带时区;不代替采集时间,同时间戳修订通过sourceDataVersion识别 }
|
||||||
|
reasonCode: { type: string, nullable: true, description: 可程序识别的状态原因 }
|
||||||
|
message: { type: string, nullable: true, description: 中文状态说明 }
|
||||||
HydrogenStationQuery:
|
HydrogenStationQuery:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -608,8 +723,11 @@ components:
|
|||||||
description: true仅合作站,false仅外部站,省略则返回全部
|
description: true仅合作站,false仅外部站,省略则返回全部
|
||||||
HydrogenResult:
|
HydrogenResult:
|
||||||
type: object
|
type: object
|
||||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
required: [vin, plateNumber, date, hydrogenConsumptionKg, statisticsStartTime, statisticsEndTime, updatedAt, status]
|
||||||
properties:
|
properties:
|
||||||
|
vin:
|
||||||
|
type: string
|
||||||
|
description: 授权车辆 VIN,供跨接口身份校验
|
||||||
plateNumber:
|
plateNumber:
|
||||||
type: string
|
type: string
|
||||||
date:
|
date:
|
||||||
@@ -620,17 +738,32 @@ components:
|
|||||||
format: double
|
format: double
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null
|
description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null
|
||||||
|
statisticsStartTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: FINAL 证据区间最早开始时间;PRELIMINARY 仅有结束水位故为 null;缺失或异常证据为 null。边界是证据包络,不承诺连续覆盖
|
||||||
|
statisticsEndTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: FINAL 证据区间最晚结束时间,PRELIMINARY 为真实 lastEventTime 水位;缺失或异常证据为 null;不是 API 查询时间
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: 同一用氢统计行的数据库更新时间,RFC 3339 带时区;无统计行时为 null
|
||||||
calculationPhase:
|
calculationPhase:
|
||||||
type: string
|
type: string
|
||||||
enum: [PRELIMINARY, FINAL]
|
enum: [PRELIMINARY, FINAL]
|
||||||
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
|
description: PRELIMINARY 仅供标注为初步的日内监控;FINAL 表示批量重算已完成,补传或算法调整仍可能再次重算,不保证固定结算时刻
|
||||||
algorithmVersion:
|
algorithmVersion:
|
||||||
type: string
|
type: string
|
||||||
description: 氢耗计算算法版本
|
description: 氢耗计算算法版本
|
||||||
qualityStatus:
|
qualityStatus:
|
||||||
type: string
|
type: string
|
||||||
enum: [OK, SUSPECT, NO_DATA]
|
enum: [OK, SUSPECT, NO_DATA]
|
||||||
description: 氢耗计算质量状态
|
description: OK 对应 NORMAL;SUSPECT 对应 DATA_ANOMALY 并保留数值供审计,不得用于正常百公里氢耗;NO_DATA 对应无可用统计
|
||||||
qualityReason:
|
qualityReason:
|
||||||
type: string
|
type: string
|
||||||
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
|
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
|
||||||
@@ -638,7 +771,7 @@ components:
|
|||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
MileageResult:
|
MileageResult:
|
||||||
type: object
|
type: object
|
||||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
|
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, statisticsStartTime, statisticsEndTime, status]
|
||||||
properties:
|
properties:
|
||||||
vin:
|
vin:
|
||||||
type: string
|
type: string
|
||||||
@@ -651,12 +784,22 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 单日里程,km;当日无记录但存在历史累计里程时为 0
|
description: 相邻自然日同来源累计值之差,km;跨缺报期增量计入恢复日;缺报日为 0;首次基线、来源变化和累计异常时为 null
|
||||||
totalMileageKm:
|
totalMileageKm:
|
||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
nullable: true
|
nullable: true
|
||||||
description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
|
description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||||
|
statisticsStartTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: 日里程起点累计读数的时间;可跨越多个缺报日;历史结转补零或无数据时为 null
|
||||||
|
statisticsEndTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: 当日所选来源最晚 latest_event_time,等于 dataTime;历史结转补零或无数据时为 null。与用氢接口无共同快照保证
|
||||||
dataTime:
|
dataTime:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -675,8 +818,8 @@ components:
|
|||||||
dataQuality:
|
dataQuality:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
enum: [TOTAL_MILEAGE_ROLLBACK]
|
enum: [TOTAL_MILEAGE_ROLLBACK, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
|
||||||
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
|
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
|
||||||
status:
|
status:
|
||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
MileageRangeResult:
|
MileageRangeResult:
|
||||||
@@ -719,8 +862,8 @@ components:
|
|||||||
dataQuality:
|
dataQuality:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
enum: [TOTAL_MILEAGE_ROLLBACK]
|
enum: [TOTAL_MILEAGE_ROLLBACK, ODOMETER_SOURCE_CHANGED, NO_PREVIOUS_BASELINE, PREVIOUS_ODOMETER_ANOMALY, CARRIED_FORWARD, outside_daily_range, INVALID_DELTA]
|
||||||
description: status=DATA_ANOMALY 时的异常原因;累计里程回退时为 TOTAL_MILEAGE_ROLLBACK
|
description: CARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
|
||||||
status:
|
status:
|
||||||
$ref: '#/components/schemas/DataStatus'
|
$ref: '#/components/schemas/DataStatus'
|
||||||
DataStatus:
|
DataStatus:
|
||||||
@@ -846,10 +989,120 @@ components:
|
|||||||
$ref: '#/components/schemas/StationaryVehicleResult'
|
$ref: '#/components/schemas/StationaryVehicleResult'
|
||||||
RealtimeVehicleResult:
|
RealtimeVehicleResult:
|
||||||
type: object
|
type: object
|
||||||
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
required: [vin, plateNumber, online, motionStatus, locationAvailable, status, remainingHydrogenKg, remainingHydrogenPercent, hydrogenRecordTime, hydrogenDataStatus, remainingHydrogenKgStatus, remainingHydrogenPercentStatus, hydrogenValueSource, hydrogenSourceProtocol, hydrogenStaleAfterSeconds, hydrogenExpectedIntervalSeconds, remainingHydrogenPercentSource, hydrogenFullCapacityKg, hydrogenTankCapacityL, hydrogenFullPressureMPa, hydrogenReferenceTemperatureC, hydrogenEstimatePressureMPa, hydrogenEstimateTemperatureC, hydrogenPressureTemperatureSource, hydrogenCalculationVersion, hydrogenCapacitySource, hydrogenPercentReason, gpsFixStatus, locationRecordTime, coordinateSystem]
|
||||||
properties:
|
properties:
|
||||||
vin: { type: string }
|
vin: { type: string }
|
||||||
plateNumber: { type: string }
|
plateNumber: { type: string }
|
||||||
|
remainingHydrogenKg:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
minimum: 0
|
||||||
|
maximum: 500
|
||||||
|
description: 全车氢质量 kg;优先 GB32960 广东扩展终端上报(0–200 kg),缺质量时以同帧最大氢压/氢温和 VIN 全车水容积作实气估算(0–500 kg)。真实零保留,缺失/异常为 null,陈旧值保留并标 STALE
|
||||||
|
remainingHydrogenPercent:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
description: 剩余质量除以 VIN 水容积在 35 MPa、15°C 的实气模型满充质量乘100,属于 ESTIMATED;缺容量为 null/MISSING。超过100返回 null/INVALID,不截断为100,kg独立保留;不以动力电池SOC替代
|
||||||
|
hydrogenRecordTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: 氢质量对应原始同帧实际采集时间,RFC 3339 带时区;不是请求时间或缓存更新时间
|
||||||
|
hydrogenDataStatus:
|
||||||
|
type: string
|
||||||
|
enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID]
|
||||||
|
description: NORMAL 两项有效(可包含估算,见来源);PARTIAL 部分字段有效;STALE 数据陈旧;MISSING 缺少数据或容量;UNSUPPORTED 不支持;INVALID 异常。数值和字段级状态独立判断
|
||||||
|
remainingHydrogenKgStatus:
|
||||||
|
type: string
|
||||||
|
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
|
||||||
|
remainingHydrogenPercentStatus:
|
||||||
|
type: string
|
||||||
|
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
|
||||||
|
description: 独立于 kg 状态;容量缺失 MISSING,比例超100 INVALID;有值时继承 kg 的 NORMAL 或 STALE
|
||||||
|
hydrogenValueSource:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [REPORTED, ESTIMATED]
|
||||||
|
description: kg 来源;REPORTED 仅确认终端上报,无法判断终端内部测量或估算;ESTIMATED 为平台压力/温度/容积实气模型估算
|
||||||
|
remainingHydrogenPercentSource:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [ESTIMATED]
|
||||||
|
description: 百分比始终为模型满充容量推算;与 kg 来源独立,计算前缺条件时为 null
|
||||||
|
hydrogenFullCapacityKg:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 实际用于比例分母的未舍入满充质量,kg;PressureHydrogenMassKg(35,15,VIN水容积),不是扣除残余后的可用容量
|
||||||
|
hydrogenTankCapacityL:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: VIN 配置的全车储氢水容积,L;必须 active 且大于0、不超过10000,不根据车型臆造默认值
|
||||||
|
hydrogenFullPressureMPa:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
enum: [35]
|
||||||
|
description: 本次业务确认的满充参考压力,MPa;不代表自动核验每辆车铭牌额定压力
|
||||||
|
hydrogenReferenceTemperatureC:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
enum: [15]
|
||||||
|
description: 满充参考温度,摄氏度
|
||||||
|
hydrogenEstimatePressureMPa:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 仅平台估算 kg 时使用的同帧最大氢压,MPa;支持0,不补默认值
|
||||||
|
hydrogenEstimateTemperatureC:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
description: 仅平台估算 kg 时使用的同帧最大氢温,摄氏度;模型输入范围−40–726.85°C,不是车辆安全温度阈值;缺失不补默认温度
|
||||||
|
hydrogenPressureTemperatureSource:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [MAX_SENSOR_AGGREGATE]
|
||||||
|
description: 最大氢压/最大氢温聚合读数近似;两者不保证来自同一瓶,不是逐瓶质量求和或全瓶完整性证明
|
||||||
|
hydrogenCalculationVersion:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [REAL_GAS_35MPA_15C_V1]
|
||||||
|
description: 实时储氢模型版本;不修改日用氢算法
|
||||||
|
hydrogenCapacitySource:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [vehicle_hydrogen_tank_capacity]
|
||||||
|
description: 满充容量采用的 VIN 配置数据表
|
||||||
|
hydrogenPercentReason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [MISSING_HYDROGEN_MEASUREMENT, INVALID_MASS_READING, INVALID_PRESSURE_TEMPERATURE, INCOMPLETE_PRESSURE_TEMPERATURE, MISSING_TANK_CAPACITY, MASS_CALCULATION_FAILED, EXCEEDS_NOMINAL_FULL_CAPACITY]
|
||||||
|
description: 缺少或拒绝百分比的原因;超出业务满充参考仅拒绝百分比,不伪装为100,也不据此判断车辆安全状态
|
||||||
|
hydrogenSourceProtocol:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [GB32960, MQTT, JT808]
|
||||||
|
description: 储氢数据采用的协议,独立于位置的 protocol
|
||||||
|
hydrogenStaleAfterSeconds:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: GB32960 服务时效策略为 300 秒;不是协议采样周期承诺
|
||||||
|
hydrogenExpectedIntervalSeconds:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: 当前无已确认上报周期,固定 null
|
||||||
|
gpsFixStatus:
|
||||||
|
type: string
|
||||||
|
enum: [FIXED, NO_FIX, UNKNOWN]
|
||||||
|
description: 实际位置报文定位位;GB32960 bit0=0、JT808 bit1=1 表示 FIXED;MQTT 或缺少可信定位位为 UNKNOWN。不以在线或数据年龄推断
|
||||||
|
locationRecordTime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: 位置行对应实际采集时间,独立于主记录 recordTime;历史 FIXED 不因车辆离线改变
|
||||||
|
coordinateSystem:
|
||||||
|
type: string
|
||||||
|
enum: [WGS84, GCJ02, UNKNOWN]
|
||||||
|
description: 仅 GB2025 显式坐标类型 1=WGS84、2=GCJ02,其余 UNKNOWN;GB2016/JT808/MQTT 当前无确证为 UNKNOWN,未知坐标系不得直接当高德坐标使用
|
||||||
protocol:
|
protocol:
|
||||||
type: string
|
type: string
|
||||||
enum: [GB32960, MQTT, JT808]
|
enum: [GB32960, MQTT, JT808]
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timestamps are evidence from the persisted result, never the API clock or
|
||||||
|
// separately evolving stream state. A missing boundary stays unknown.
|
||||||
|
func validStatisticsTimestamp(value string) *string {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil || t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalized := t.In(time.FixedZone("CST", 8*60*60)).Format(time.RFC3339Nano)
|
||||||
|
return &normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatedStatisticsInterval(start, end string) (*string, *string) {
|
||||||
|
a, b := validStatisticsTimestamp(start), validStatisticsTimestamp(end)
|
||||||
|
if a != nil && b != nil {
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, *a)
|
||||||
|
bt, _ := time.Parse(time.RFC3339Nano, *b)
|
||||||
|
if at.After(bt) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a, b
|
||||||
|
}
|
||||||
|
|
||||||
|
// FINAL evidence contains an envelope of calculation segments, not a promise
|
||||||
|
// of uninterrupted coverage. PRELIMINARY evidence currently only stores a
|
||||||
|
// lastEventTime watermark, so its start is intentionally null.
|
||||||
|
func hydrogenStatisticsInterval(evidence string) (*string, *string) {
|
||||||
|
var live struct {
|
||||||
|
LastEventTime string `json:"lastEventTime"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(evidence), &live) == nil && live.LastEventTime != "" {
|
||||||
|
return nil, validStatisticsTimestamp(live.LastEventTime)
|
||||||
|
}
|
||||||
|
var segments []struct {
|
||||||
|
StartTime string `json:"startTime"`
|
||||||
|
EndTime string `json:"endTime"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(evidence), &segments) != nil || len(segments) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var earliest, latest time.Time
|
||||||
|
for _, segment := range segments {
|
||||||
|
a, b := validatedStatisticsInterval(segment.StartTime, segment.EndTime)
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
at, _ := time.Parse(time.RFC3339Nano, *a)
|
||||||
|
bt, _ := time.Parse(time.RFC3339Nano, *b)
|
||||||
|
if earliest.IsZero() || at.Before(earliest) {
|
||||||
|
earliest = at
|
||||||
|
}
|
||||||
|
if latest.IsZero() || bt.After(latest) {
|
||||||
|
latest = bt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validatedStatisticsInterval(earliest.Format(time.RFC3339Nano), latest.Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHydrogenStatisticsInterval(t *testing.T) {
|
||||||
|
cases := []struct{ name, evidence, start, end string }{
|
||||||
|
{"live watermark", `{"lastEventTime":"2026-09-08T10:23:45.123Z"}`, "", "2026-09-08T18:23:45.123+08:00"},
|
||||||
|
{"final envelope", `[{"startTime":"2026-09-08T02:00:00+08:00","endTime":"2026-09-08T03:00:00+08:00"},{"startTime":"2026-09-08T01:00:00+08:00","endTime":"2026-09-08T01:30:00+08:00"}]`, "2026-09-08T01:00:00+08:00", "2026-09-08T03:00:00+08:00"},
|
||||||
|
{"missing", `null`, "", ""},
|
||||||
|
{"legacy empty", `[]`, "", ""},
|
||||||
|
{"malformed", `{`, "", ""},
|
||||||
|
{"reversed", `[{"startTime":"2026-09-08T03:00:00+08:00","endTime":"2026-09-08T02:00:00+08:00"}]`, "", ""},
|
||||||
|
{"incomplete", `[{"startTime":"2026-09-08T03:00:00+08:00"}]`, "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a, b := hydrogenStatisticsInterval(tc.evidence)
|
||||||
|
if (a == nil) != (tc.start == "") || a != nil && *a != tc.start {
|
||||||
|
t.Fatalf("start=%v", a)
|
||||||
|
}
|
||||||
|
if (b == nil) != (tc.end == "") || b != nil && *b != tc.end {
|
||||||
|
t.Fatalf("end=%v", b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMileageStatisticsIntervalDoesNotRelabelCarryForward(t *testing.T) {
|
||||||
|
value := DailyMileage{Date: "2026-09-07", StatisticsStartTime: "2026-09-07T00:01:00+08:00", DataTime: "2026-09-07T23:01:00+08:00"}
|
||||||
|
carried := MileageResult{Date: "2026-09-08"}
|
||||||
|
fillMileageResult(&carried, value, 0)
|
||||||
|
if carried.StatisticsStartTime != nil || carried.StatisticsEndTime != nil {
|
||||||
|
t.Fatalf("fabricated carry interval: %+v", carried)
|
||||||
|
}
|
||||||
|
current := MileageResult{Date: value.Date}
|
||||||
|
fillMileageResult(¤t, value, 10)
|
||||||
|
if current.StatisticsStartTime == nil || current.StatisticsEndTime == nil {
|
||||||
|
t.Fatalf("missing evidence: %+v", current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyHydrogenReadsEvidenceAndUpdateFromSameRow(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
mock.ExpectQuery("SELECT vin,DATE_FORMAT.*COALESCE\\(evidence_json,'null'\\).*DATE_FORMAT\\(updated_at").WithArgs("2026-09-08", "VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "consumption", "samples", "quality", "reason", "phase", "algorithm", "evidence", "updated"}).AddRow("VIN1", "2026-09-08", 0, 2, "OK", "", "PRELIMINARY", "V3_5", `{"lastEventTime":"2026-09-08T10:00:00+08:00"}`, "2026-09-08T10:01:00.123000+08:00"))
|
||||||
|
values, err := NewMySQLRepository(db).DailyHydrogen(context.Background(), []string{"VIN1"}, "2026-09-08")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if values["VIN1"].EvidenceJSON == "" || values["VIN1"].UpdatedAt == "" {
|
||||||
|
t.Fatal(values)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyQueryRejectsSameCountWrongAuthorizationMembership(t *testing.T) {
|
||||||
|
repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}}}
|
||||||
|
_, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"})
|
||||||
|
if err != ErrForbidden {
|
||||||
|
t.Fatalf("authorization membership mismatch must fail closed: %v", err)
|
||||||
|
}
|
||||||
|
if len(repo.dailyVINs) != 0 {
|
||||||
|
t.Fatalf("queried data after failed authorization: %v", repo.dailyVINs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyQueryIgnoresUnrequestedVINResults(t *testing.T) {
|
||||||
|
repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}}, hydrogen: map[string]DailyHydrogen{"LTEST32960VIN0002": {ConsumptionKg: 99, QualityStatus: "OK"}}}
|
||||||
|
rows, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].VIN != "LTEST32960VIN0001" || rows[0].HydrogenConsumptionKg != nil {
|
||||||
|
t.Fatalf("unexpected VIN leaked: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,3 +79,56 @@ func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T) {
|
||||||
|
// Public additions must be discoverable in both the machine contract and the
|
||||||
|
// human-readable documentation, including their null/quality companions.
|
||||||
|
for _, field := range []string{
|
||||||
|
"remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime",
|
||||||
|
"hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus",
|
||||||
|
"hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds",
|
||||||
|
"hydrogenExpectedIntervalSeconds", "gpsFixStatus", "locationRecordTime",
|
||||||
|
"coordinateSystem", "statisticsStartTime", "statisticsEndTime",
|
||||||
|
"updatedAt", "calculationPhase", "qualityStatus", "algorithmVersion",
|
||||||
|
"remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL",
|
||||||
|
"hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa",
|
||||||
|
"hydrogenEstimateTemperatureC", "hydrogenPressureTemperatureSource", "hydrogenCalculationVersion",
|
||||||
|
"hydrogenCapacitySource", "hydrogenPercentReason",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(openAPISpec), field+":") {
|
||||||
|
t.Errorf("OpenAPI missing live contract field %s", field)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(simpleDocsHTML), field) {
|
||||||
|
t.Errorf("HTML missing live contract field %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} {
|
||||||
|
for _, boundary := range []string{"PARTIAL", "UNSUPPORTED", "UNKNOWN", "PRELIMINARY", "FINAL", "SUSPECT", "REPORTED", "ESTIMATED", "MAX_SENSOR_AGGREGATE", "EXCEEDS_NOMINAL_FULL_CAPACITY", "REAL_GAS_35MPA_15C_V1", "共同快照", "null"} {
|
||||||
|
if !strings.Contains(doc, boundary) {
|
||||||
|
t.Errorf("%s missing availability/comparability boundary %q", name, boundary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(openAPISpec), "MEASURED") || strings.Contains(string(simpleDocsHTML), "MEASURED") {
|
||||||
|
t.Error("reported hydrogen must not promise a measured terminal value")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(simpleDocsHTML), "新接入请使用 sourceProtocol") {
|
||||||
|
t.Error("HTML must not advertise sourceProtocol as realtime field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *testing.T) {
|
||||||
|
for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} {
|
||||||
|
for _, want := range []string{
|
||||||
|
"/api/v1/vehicles/hydrogen-remaining/history/query",
|
||||||
|
"requestId", "maxTimeDifferenceSeconds", "sourceRecordId",
|
||||||
|
"hydrogenCapacityVersion", "sourceDataVersion", "reasonCode", "2026-08-01", "FORBIDDEN", "ERROR",
|
||||||
|
"STALE", "429", "Retry-After", "event_time", "历史容量", "非原子",
|
||||||
|
"20个不同点", "去重",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(doc, want) {
|
||||||
|
t.Errorf("%s missing history contract evidence/failure boundary %q", name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler {
|
|||||||
|
|
||||||
func (h *Handler) registerExternalDataRoutes() {
|
func (h *Handler) registerExternalDataRoutes() {
|
||||||
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
||||||
|
h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen)
|
||||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||||
@@ -109,6 +110,7 @@ func (h *Handler) registerPortalRoutes() {
|
|||||||
func NewDataHandler(service *Service) *Handler {
|
func NewDataHandler(service *Service) *Handler {
|
||||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||||
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
||||||
|
handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen)
|
||||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||||
@@ -123,7 +125,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsPublicPath(path string) bool {
|
func IsPublicPath(path string) bool {
|
||||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -754,6 +756,7 @@ func requestRemoteAddress(r *http.Request) string {
|
|||||||
|
|
||||||
func dataProducts() []DataProduct {
|
func dataProducts() []DataProduct {
|
||||||
return []DataProduct{
|
return []DataProduct{
|
||||||
|
{Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"},
|
||||||
{
|
{
|
||||||
Code: "daily_hydrogen", Name: "单日用氢量",
|
Code: "daily_hydrogen", Name: "单日用氢量",
|
||||||
Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
|
Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const HistoricalHydrogenQueryPath = "/api/v1/vehicles/hydrogen-remaining/history/query"
|
||||||
|
const historicalHydrogenBudget = 9 * time.Second
|
||||||
|
|
||||||
|
var historicalVINPattern = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`)
|
||||||
|
|
||||||
|
type HistoricalHydrogenQuery struct {
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
}
|
||||||
|
type HistoricalHydrogenRequest struct {
|
||||||
|
Queries []HistoricalHydrogenQuery `json:"queries"`
|
||||||
|
MaxTimeDifferenceSeconds *int `json:"maxTimeDifferenceSeconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional numeric/protocol fields may be omitted, but explicit JSON null is
|
||||||
|
// not a valid integer or protocol identifier. Preserve strict unknown-field
|
||||||
|
// rejection even though this request has a custom decoder.
|
||||||
|
func (r *HistoricalHydrogenRequest) UnmarshalJSON(data []byte) error {
|
||||||
|
type plain HistoricalHydrogenRequest
|
||||||
|
var decoded plain
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&decoded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value, exists := raw["maxTimeDifferenceSeconds"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||||
|
return fmt.Errorf("maxTimeDifferenceSeconds must be an integer")
|
||||||
|
}
|
||||||
|
var queries []map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw["queries"], &queries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, query := range queries {
|
||||||
|
if value, exists := query["protocol"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||||
|
return fmt.Errorf("protocol must be a string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*r = HistoricalHydrogenRequest(decoded)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoricalHydrogenResult struct {
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
PlateNumber *string `json:"plateNumber"`
|
||||||
|
QueryTime string `json:"queryTime"`
|
||||||
|
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
||||||
|
HydrogenRecordTime *string `json:"hydrogenRecordTime"`
|
||||||
|
TimeDifferenceSeconds *float64 `json:"timeDifferenceSeconds"`
|
||||||
|
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"`
|
||||||
|
HydrogenValueSource *string `json:"hydrogenValueSource"`
|
||||||
|
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"`
|
||||||
|
SourceRecordID *string `json:"sourceRecordId"`
|
||||||
|
SourceDataVersion *string `json:"sourceDataVersion"`
|
||||||
|
HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"`
|
||||||
|
HydrogenCapacityVersion *string `json:"hydrogenCapacityVersion"`
|
||||||
|
UpdatedAt *string `json:"updatedAt"`
|
||||||
|
ReasonCode *string `json:"reasonCode"`
|
||||||
|
Message *string `json:"message"`
|
||||||
|
HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"`
|
||||||
|
HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"`
|
||||||
|
HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"`
|
||||||
|
HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoricalHydrogenRepository interface {
|
||||||
|
HistoricalHydrogen(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error)
|
||||||
|
HistoricalHydrogenAuthorized(context.Context, uint64, string, time.Time, time.Time) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedHistoricalHydrogenQuery struct {
|
||||||
|
HistoricalHydrogenQuery
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateHistoricalHydrogen(request HistoricalHydrogenRequest, now time.Time) ([]parsedHistoricalHydrogenQuery, int, error) {
|
||||||
|
if len(request.Queries) < 1 || len(request.Queries) > 200 {
|
||||||
|
return nil, 0, fmt.Errorf("%w: queries must contain 1-200 items", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
tolerance := 300
|
||||||
|
if request.MaxTimeDifferenceSeconds != nil {
|
||||||
|
tolerance = *request.MaxTimeDifferenceSeconds
|
||||||
|
}
|
||||||
|
if tolerance < 0 || tolerance > 300 {
|
||||||
|
return nil, 0, fmt.Errorf("%w: tolerance must be 0-300", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(request.Queries))
|
||||||
|
parsed := make([]parsedHistoricalHydrogenQuery, 0, len(request.Queries))
|
||||||
|
lower := time.Date(2026, 8, 1, 0, 0, 0, 0, s.location)
|
||||||
|
for _, query := range request.Queries {
|
||||||
|
if strings.TrimSpace(query.RequestID) == "" || len(query.RequestID) > 128 || seen[query.RequestID] {
|
||||||
|
return nil, 0, fmt.Errorf("%w: requestId must be nonempty and unique", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
seen[query.RequestID] = true
|
||||||
|
query.VIN = strings.ToUpper(query.VIN)
|
||||||
|
if !historicalVINPattern.MatchString(query.VIN) {
|
||||||
|
return nil, 0, fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
at, err := time.ParseInLocation("2006-01-02 15:04:05", query.Time, s.location)
|
||||||
|
if err != nil || at.Format("2006-01-02 15:04:05") != query.Time {
|
||||||
|
return nil, 0, fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if at.After(now) || at.Before(lower) {
|
||||||
|
return nil, 0, fmt.Errorf("%w: time outside supported history", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
if query.Protocol == "" {
|
||||||
|
query.Protocol = vehicleprotocol.GB32960
|
||||||
|
} else if protocol, ok := vehicleprotocol.Canonical(query.Protocol); !ok || protocol != query.Protocol {
|
||||||
|
return nil, 0, fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
parsed = append(parsed, parsedHistoricalHydrogenQuery{HistoricalHydrogenQuery: query, at: at})
|
||||||
|
}
|
||||||
|
return parsed, tolerance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) QueryHistoricalHydrogen(ctx context.Context, appKey, traceID string, request HistoricalHydrogenRequest) ([]HistoricalHydrogenResult, error) {
|
||||||
|
now := s.now().In(s.location)
|
||||||
|
queries, tolerance, err := s.validateHistoricalHydrogen(request, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !appKeyPattern.MatchString(appKey) {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
bounded, cancel := context.WithTimeout(ctx, historicalHydrogenBudget)
|
||||||
|
defer cancel()
|
||||||
|
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUnauthorized) {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
release, err := s.historicalLimiter.acquire(app.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
results := make([]HistoricalHydrogenResult, len(queries))
|
||||||
|
for i, query := range queries {
|
||||||
|
results[i] = historicalHydrogenFailure(query, "ERROR", "UPSTREAM_TIMEOUT", "历史查询超时,请重试")
|
||||||
|
}
|
||||||
|
repository, ok := s.repository.(HistoricalHydrogenRepository)
|
||||||
|
if !ok {
|
||||||
|
for i, query := range queries {
|
||||||
|
results[i] = historicalHydrogenFailure(query, "ERROR", "HISTORY_UNAVAILABLE", "历史数据服务暂不可用")
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
// Deduplicate identical sampling points within this batch. Every requestId
|
||||||
|
// still receives its own result, while duplicate endpoints share one source
|
||||||
|
// revision and one pair of authorization checks.
|
||||||
|
var unique []parsedHistoricalHydrogenQuery
|
||||||
|
var destinations [][]int
|
||||||
|
indexes := make(map[string]int, len(queries))
|
||||||
|
for i, query := range queries {
|
||||||
|
key := query.VIN + "\x00" + query.Time + "\x00" + query.Protocol
|
||||||
|
index, exists := indexes[key]
|
||||||
|
if !exists {
|
||||||
|
index = len(unique)
|
||||||
|
indexes[key] = index
|
||||||
|
unique = append(unique, query)
|
||||||
|
destinations = append(destinations, nil)
|
||||||
|
}
|
||||||
|
destinations[index] = append(destinations[index], i)
|
||||||
|
}
|
||||||
|
type completed struct {
|
||||||
|
index int
|
||||||
|
result HistoricalHydrogenResult
|
||||||
|
}
|
||||||
|
jobs := make(chan int, len(unique))
|
||||||
|
out := make(chan completed, len(unique))
|
||||||
|
for i := range unique {
|
||||||
|
jobs <- i
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
workers := 4
|
||||||
|
if len(unique) < workers {
|
||||||
|
workers = len(unique)
|
||||||
|
}
|
||||||
|
for worker := 0; worker < workers; worker++ {
|
||||||
|
go func() {
|
||||||
|
for i := range jobs {
|
||||||
|
if bounded.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out <- completed{i, s.queryHistoricalHydrogenItem(bounded, repository, app.ID, unique[i], tolerance, now)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for count := 0; count < len(unique); count++ {
|
||||||
|
select {
|
||||||
|
case result := <-out:
|
||||||
|
for _, destination := range destinations[result.index] {
|
||||||
|
item := result.result
|
||||||
|
item.RequestID = queries[destination].RequestID
|
||||||
|
results[destination] = item
|
||||||
|
}
|
||||||
|
case <-bounded.Done():
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use the remaining shared budget; avoid extending a timed-out batch for audit.
|
||||||
|
if bounded.Err() == nil {
|
||||||
|
_ = s.repository.Audit(bounded, app.ID, "historical_hydrogen_query", "success", traceID, len(results), "")
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func historicalHydrogenFailure(query parsedHistoricalHydrogenQuery, status, reason, message string) HistoricalHydrogenResult {
|
||||||
|
return HistoricalHydrogenResult{RequestID: query.RequestID, VIN: query.VIN, QueryTime: query.Time, RemainingHydrogenKgStatus: status, ReasonCode: historicalString(reason), Message: historicalString(message)}
|
||||||
|
}
|
||||||
|
func historicalString(value string) *string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
func historicalTime(value time.Time, location *time.Location) *string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := value.In(location).Format(time.RFC3339Nano)
|
||||||
|
return &text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) queryHistoricalHydrogenItem(ctx context.Context, repository HistoricalHydrogenRepository, appID uint64, query parsedHistoricalHydrogenQuery, tolerance int, now time.Time) HistoricalHydrogenResult {
|
||||||
|
failure := func(err error) HistoricalHydrogenResult {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return historicalHydrogenFailure(query, "ERROR", "UPSTREAM_TIMEOUT", "历史查询超时,请重试")
|
||||||
|
}
|
||||||
|
return historicalHydrogenFailure(query, "ERROR", "UPSTREAM_FAILURE", "历史查询失败,请重试")
|
||||||
|
}
|
||||||
|
allowed, err := repository.HistoricalHydrogenAuthorized(ctx, appID, query.VIN, query.at, now)
|
||||||
|
if err != nil {
|
||||||
|
return failure(err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return historicalHydrogenFailure(query, "FORBIDDEN", "HISTORY_FORBIDDEN", "车辆或历史时刻未授权")
|
||||||
|
}
|
||||||
|
if query.Protocol != vehicleprotocol.GB32960 {
|
||||||
|
return historicalHydrogenFailure(query, "UNSUPPORTED", "PROTOCOL_UNSUPPORTED", "该协议暂不支持历史储氢质量")
|
||||||
|
}
|
||||||
|
point, err := repository.HistoricalHydrogen(ctx, query.VIN, query.at, query.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
return failure(err)
|
||||||
|
}
|
||||||
|
if point == nil {
|
||||||
|
return historicalHydrogenFailure(query, "NO_DATA", "NO_HISTORICAL_SAMPLE", "查询时刻前没有历史储氢采样")
|
||||||
|
}
|
||||||
|
if point.VIN != query.VIN || point.Protocol != query.Protocol {
|
||||||
|
return historicalHydrogenFailure(query, "ERROR", "SOURCE_IDENTITY_MISMATCH", "历史采样身份不一致")
|
||||||
|
}
|
||||||
|
if point.ObservedAt.IsZero() || point.ObservedAt.After(query.at) {
|
||||||
|
return historicalHydrogenFailure(query, "ERROR", "INVALID_SAMPLE_TIME", "历史采样时间不符合查询条件")
|
||||||
|
}
|
||||||
|
allowed, err = repository.HistoricalHydrogenAuthorized(ctx, appID, query.VIN, point.ObservedAt, now)
|
||||||
|
if err != nil {
|
||||||
|
return failure(err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return historicalHydrogenFailure(query, "FORBIDDEN", "HISTORY_FORBIDDEN", "车辆或历史时刻未授权")
|
||||||
|
}
|
||||||
|
result := HistoricalHydrogenResult{RequestID: query.RequestID, VIN: query.VIN, QueryTime: query.Time, RemainingHydrogenKgStatus: point.Status, HydrogenRecordTime: historicalTime(point.ObservedAt, s.location), HydrogenValueSource: historicalString(point.ValueSource), HydrogenSourceProtocol: historicalString(point.Protocol), SourceRecordID: historicalString(point.RecordID), SourceDataVersion: historicalString(point.SourceDataVersion), HydrogenCalculationVersion: historicalString(point.CalculationVersion), HydrogenCapacityVersion: historicalString(point.CapacityVersion), UpdatedAt: historicalTime(point.UpdatedAt, s.location), ReasonCode: historicalString(point.ReasonCode), Message: historicalString(point.Message), HydrogenEstimatePressureMPa: point.EstimatePressureMPa, HydrogenEstimateTemperatureC: point.EstimateTemperatureC, HydrogenTankCapacityL: point.TankCapacityL, HydrogenPressureTemperatureSource: historicalString(point.PressureTemperatureSource)}
|
||||||
|
difference := query.at.Sub(point.ObservedAt).Seconds()
|
||||||
|
result.TimeDifferenceSeconds = &difference
|
||||||
|
switch result.RemainingHydrogenKgStatus {
|
||||||
|
case "NORMAL", "NO_DATA", "MISSING", "STALE", "UNSUPPORTED", "INVALID":
|
||||||
|
default:
|
||||||
|
return historicalHydrogenFailure(query, "ERROR", "INVALID_SOURCE_STATUS", "历史数据状态异常")
|
||||||
|
}
|
||||||
|
if result.RemainingHydrogenKgStatus == "NORMAL" {
|
||||||
|
if point.RemainingHydrogenKg == nil {
|
||||||
|
result.RemainingHydrogenKgStatus = "MISSING"
|
||||||
|
result.ReasonCode = historicalString("MISSING_MASS")
|
||||||
|
} else if math.IsNaN(*point.RemainingHydrogenKg) || math.IsInf(*point.RemainingHydrogenKg, 0) || *point.RemainingHydrogenKg < 0 {
|
||||||
|
result.RemainingHydrogenKgStatus = "INVALID"
|
||||||
|
result.ReasonCode = historicalString("INVALID_MASS")
|
||||||
|
} else if difference > float64(tolerance) {
|
||||||
|
result.RemainingHydrogenKgStatus = "STALE"
|
||||||
|
result.ReasonCode = historicalString("SAMPLE_OUTSIDE_TOLERANCE")
|
||||||
|
result.Message = historicalString("采样时间早于允许容差")
|
||||||
|
} else if point.ValueSource == "ESTIMATED" && (point.CalculationVersion == "" || point.CapacityVersion == "" || point.EstimatePressureMPa == nil || point.EstimateTemperatureC == nil || point.TankCapacityL == nil || point.PressureTemperatureSource == "") {
|
||||||
|
result.RemainingHydrogenKgStatus = "MISSING"
|
||||||
|
result.ReasonCode = historicalString("MISSING_ESTIMATE_EVIDENCE")
|
||||||
|
} else {
|
||||||
|
value := *point.RemainingHydrogenKg
|
||||||
|
result.RemainingHydrogenKg = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) HistoricalHydrogenAuthorized(ctx context.Context, appID uint64, vin string, at, now time.Time) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM vehicle_open_app a JOIN vehicle_open_app_vehicle g ON g.app_id=a.id WHERE a.id=? AND a.status='enabled' AND a.valid_from<=? AND (a.valid_to IS NULL OR a.valid_to>?) AND a.valid_from<=? AND (a.valid_to IS NULL OR a.valid_to>?) AND BINARY g.vin=BINARY ? AND g.valid_from<=? AND (g.valid_to IS NULL OR g.valid_to>?))`, appID, now, now, at, at, vin, at, at).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type historicalHydrogenRateError struct{ RetryAfter int }
|
||||||
|
|
||||||
|
func (e *historicalHydrogenRateError) Error() string {
|
||||||
|
return "historical hydrogen rate limit exceeded"
|
||||||
|
}
|
||||||
|
|
||||||
|
type historicalHydrogenRateEntry struct {
|
||||||
|
start time.Time
|
||||||
|
count, active int
|
||||||
|
}
|
||||||
|
type historicalHydrogenLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[uint64]*historicalHydrogenRateEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *historicalHydrogenLimiter) acquire(appID uint64, now time.Time) (func(), error) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.entries == nil {
|
||||||
|
l.entries = make(map[uint64]*historicalHydrogenRateEntry)
|
||||||
|
}
|
||||||
|
for id, entry := range l.entries {
|
||||||
|
if entry.active == 0 && now.Sub(entry.start) >= 2*time.Minute {
|
||||||
|
delete(l.entries, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry := l.entries[appID]
|
||||||
|
if entry == nil {
|
||||||
|
entry = &historicalHydrogenRateEntry{start: now}
|
||||||
|
l.entries[appID] = entry
|
||||||
|
}
|
||||||
|
if now.Sub(entry.start) >= time.Minute {
|
||||||
|
entry.start = now
|
||||||
|
entry.count = 0
|
||||||
|
}
|
||||||
|
if entry.active >= 2 {
|
||||||
|
return nil, &historicalHydrogenRateError{RetryAfter: 1}
|
||||||
|
}
|
||||||
|
if entry.count >= 30 {
|
||||||
|
return nil, &historicalHydrogenRateError{RetryAfter: max(1, int(math.Ceil(time.Minute.Seconds()-now.Sub(entry.start).Seconds())))}
|
||||||
|
}
|
||||||
|
entry.count++
|
||||||
|
entry.active++
|
||||||
|
return func() { l.mu.Lock(); entry.active--; l.mu.Unlock() }, nil
|
||||||
|
}
|
||||||
|
func (h *Handler) historicalHydrogen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
traceID := externalTraceID(r)
|
||||||
|
var request HistoricalHydrogenRequest
|
||||||
|
if !decodeExternalBody(w, r, traceID, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.QueryHistoricalHydrogen(r.Context(), externalBearer(r), traceID, request)
|
||||||
|
if err != nil {
|
||||||
|
var limit *historicalHydrogenRateError
|
||||||
|
if errors.As(err, &limit) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
|
||||||
|
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMITED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeExternalError(w, traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenCrossMidnightKeepsInstantAndRevisionEvidence(t *testing.T) {
|
||||||
|
service, repository, _ := historicalTestService()
|
||||||
|
start := time.Date(2026, 8, 3, 23, 59, 55, 0, service.location)
|
||||||
|
end := start.Add(20 * time.Second)
|
||||||
|
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
point := historicalTestPoint(at, 30.50012345)
|
||||||
|
point.RecordID = at.Format(time.RFC3339)
|
||||||
|
if at.Equal(end) {
|
||||||
|
point.RemainingHydrogenKg = realLiveFloat(29.80012345)
|
||||||
|
point.SourceDataVersion = "end-v2"
|
||||||
|
}
|
||||||
|
return point, nil
|
||||||
|
}
|
||||||
|
request := HistoricalHydrogenRequest{Queries: []HistoricalHydrogenQuery{
|
||||||
|
{RequestID: "job-end", VIN: historicalTestVIN, Time: end.Format("2006-01-02 15:04:05")},
|
||||||
|
{RequestID: "job-start", VIN: historicalTestVIN, Time: start.Format("2006-01-02 15:04:05")},
|
||||||
|
}}
|
||||||
|
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "cross-midnight", request)
|
||||||
|
if err != nil || len(results) != 2 {
|
||||||
|
t.Fatalf("%+v %v", results, err)
|
||||||
|
}
|
||||||
|
for i, at := range []time.Time{end, start} {
|
||||||
|
got := results[i]
|
||||||
|
if got.RequestID != request.Queries[i].RequestID || got.QueryTime != request.Queries[i].Time || got.HydrogenRecordTime == nil || *got.HydrogenRecordTime != at.Format(time.RFC3339) || got.RemainingHydrogenKgStatus != "NORMAL" || *got.TimeDifferenceSeconds != 0 {
|
||||||
|
t.Fatalf("midnight instant lost: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *results[0].RemainingHydrogenKg != 29.80012345 || *results[1].RemainingHydrogenKg != 30.50012345 || *results[0].SourceRecordID == *results[1].SourceRecordID || *results[0].SourceDataVersion == *results[1].SourceDataVersion {
|
||||||
|
t.Fatal("endpoint precision or distinct provenance lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HistoricalHydrogenStart is the first supported event-time boundary. This is
|
||||||
|
// an API coverage boundary, not a claim that every VIN has retained records.
|
||||||
|
var HistoricalHydrogenStart = time.Date(2026, 8, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
|
||||||
|
type HistoricalHydrogenPoint struct {
|
||||||
|
VIN, Protocol, RecordID, SourceDataVersion string
|
||||||
|
ObservedAt, ReceivedAt, UpdatedAt time.Time
|
||||||
|
RemainingHydrogenKg *float64
|
||||||
|
Status, ValueSource, CalculationVersion, CapacityVersion, ReasonCode, Message string
|
||||||
|
EstimatePressureMPa, EstimateTemperatureC, TankCapacityL *float64
|
||||||
|
PressureTemperatureSource string
|
||||||
|
}
|
||||||
|
|
||||||
|
type historicalHydrogenFrame struct {
|
||||||
|
VIN, Protocol, EventID, FrameID, Parsed, ParseStatus string
|
||||||
|
EventMS, ReceivedMS, TimestampMS sql.NullInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) HistoricalHydrogen(ctx context.Context, vin string, at time.Time, protocol string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = "GB32960"
|
||||||
|
}
|
||||||
|
if protocol != "GB32960" {
|
||||||
|
return &HistoricalHydrogenPoint{VIN: vin, Protocol: protocol, Status: "UNSUPPORTED", ReasonCode: "UNSUPPORTED_HYDROGEN_PROTOCOL", Message: "该协议暂不支持历史剩余氢质量"}, nil
|
||||||
|
}
|
||||||
|
if r.tdengine == nil {
|
||||||
|
return nil, errors.New("TDengine is not configured for historical hydrogen query")
|
||||||
|
}
|
||||||
|
query, err := historicalHydrogenQuery(r.tdDatabase, vin, at, protocol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for candidate := 0; candidate < 32; candidate++ {
|
||||||
|
var frame historicalHydrogenFrame
|
||||||
|
frame, err = r.loadHistoricalHydrogenCandidate(ctx, query)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if frame.VIN != vin || frame.Protocol != protocol {
|
||||||
|
return nil, errors.New("historical hydrogen source identity mismatch")
|
||||||
|
}
|
||||||
|
if err := r.hydrateHistoricalHydrogenFrame(ctx, &frame); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
point := historicalHydrogenFromFrame(frame)
|
||||||
|
if point.ObservedAt.IsZero() || point.ObservedAt.After(at) || point.ObservedAt.Before(HistoricalHydrogenStart) {
|
||||||
|
return nil, errors.New("historical hydrogen source time out of bounds")
|
||||||
|
}
|
||||||
|
if point.ReasonCode != "MISSING_HYDROGEN_MEASUREMENT" {
|
||||||
|
return &point, nil
|
||||||
|
}
|
||||||
|
// A hydrated chunk can prove that a candidate has no hydrogen fields. Only
|
||||||
|
// in that case move to the next record; invalid/incomplete readings stop.
|
||||||
|
clause := fmt.Sprintf(" AND (event_time<%d OR (event_time=%d AND ts<%d))", frame.EventMS.Int64, frame.EventMS.Int64, frame.TimestampMS.Int64)
|
||||||
|
query = strings.Replace(query, " ORDER BY", clause+" ORDER BY", 1)
|
||||||
|
}
|
||||||
|
return nil, errors.New("historical hydrogen candidate budget exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func historicalHydrogenQuery(database, vin string, at time.Time, protocol string) (string, error) {
|
||||||
|
if !validTDIdentifier(database) || vin == "" || protocol != "GB32960" || at.Before(HistoricalHydrogenStart) {
|
||||||
|
return "", errors.New("invalid historical hydrogen query")
|
||||||
|
}
|
||||||
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||||
|
fields := []string{`parsed_json LIKE '%"chunked":true%'`}
|
||||||
|
for _, key := range append(append([]string{}, hydrogenMassFields...), realtimeHydrogenPressureField, realtimeHydrogenTemperatureField) {
|
||||||
|
fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%"))
|
||||||
|
}
|
||||||
|
// Raw rows are JSON-marshaled by ingestion (compact key-colon syntax). Do not
|
||||||
|
// filter invalid values/parse status or restrict arrival <= query event time:
|
||||||
|
// a delayed upload remains valid historical evidence for its collection time.
|
||||||
|
return `SELECT vin,protocol,event_id,frame_id,CAST(event_time AS BIGINT),CAST(received_at AS BIGINT),CAST(ts AS BIGINT),parsed_json,parse_status FROM ` + database + `.raw_frames WHERE vin=` + quote(vin) + ` AND protocol=` + quote(protocol) + ` AND event_time>=` + strconv.FormatInt(HistoricalHydrogenStart.UnixMilli(), 10) + ` AND event_time<=` + strconv.FormatInt(at.UnixMilli(), 10) + ` AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 2`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func historicalHydrogenFromFrame(frame historicalHydrogenFrame) HistoricalHydrogenPoint {
|
||||||
|
point := HistoricalHydrogenPoint{VIN: frame.VIN, Protocol: frame.Protocol, Status: "MISSING"}
|
||||||
|
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
||||||
|
point.ObservedAt = time.UnixMilli(frame.EventMS.Int64)
|
||||||
|
}
|
||||||
|
if frame.ReceivedMS.Valid && frame.ReceivedMS.Int64 > 0 {
|
||||||
|
point.ReceivedAt = time.UnixMilli(frame.ReceivedMS.Int64)
|
||||||
|
point.UpdatedAt = point.ReceivedAt
|
||||||
|
}
|
||||||
|
// The content fingerprint exposes corrections even if storage keeps the
|
||||||
|
// original arrival timestamp. It is not part of calculation comparability.
|
||||||
|
identity := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d", frame.VIN, frame.Protocol, frame.EventID, frame.FrameID, frame.EventMS.Int64, frame.TimestampMS.Int64)))
|
||||||
|
point.RecordID = "raw-sha256:" + hex.EncodeToString(identity[:])
|
||||||
|
content := sha256.Sum256([]byte(frame.Parsed + "\x00" + frame.ParseStatus))
|
||||||
|
point.SourceDataVersion = "sha256:" + hex.EncodeToString(content[:])
|
||||||
|
fail := func(status, reason, message string) HistoricalHydrogenPoint {
|
||||||
|
point.Status = status
|
||||||
|
point.ReasonCode = reason
|
||||||
|
point.Message = message
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
if frame.ParseStatus != "OK" {
|
||||||
|
return fail("INVALID", "RAW_FRAME_PARSE_FAILED", "历史原始记录解析异常")
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if json.Unmarshal([]byte(frame.Parsed), &fields) != nil {
|
||||||
|
return fail("INVALID", "INVALID_RAW_JSON", "历史原始数据格式异常")
|
||||||
|
}
|
||||||
|
for _, key := range hydrogenMassFields {
|
||||||
|
value, exists := fields[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mass, valid := numericValue(value)
|
||||||
|
point.ValueSource = "REPORTED"
|
||||||
|
point.CalculationVersion = "REPORTED_HYDROGEN_KG_V1"
|
||||||
|
if !valid || math.IsNaN(mass) || math.IsInf(mass, 0) || mass < 0 || mass > 200 {
|
||||||
|
return fail("INVALID", "INVALID_MASS_READING", "历史剩余氢质量读数无效")
|
||||||
|
}
|
||||||
|
point.RemainingHydrogenKg = &mass
|
||||||
|
point.Status = "NORMAL"
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
pv, pExists := fields[realtimeHydrogenPressureField]
|
||||||
|
tv, tExists := fields[realtimeHydrogenTemperatureField]
|
||||||
|
if !pExists && !tExists {
|
||||||
|
return fail("MISSING", "MISSING_HYDROGEN_MEASUREMENT", "历史原始记录缺少氢质量及温压")
|
||||||
|
}
|
||||||
|
pressure, pOK := numericValue(pv)
|
||||||
|
temperature, tOK := numericValue(tv)
|
||||||
|
if (pExists && (!pOK || !finiteRealtime(pressure) || pressure < 0 || pressure > 70)) || (tExists && (!tOK || !finiteRealtime(temperature) || temperature < -40 || temperature > 726.85)) {
|
||||||
|
return fail("INVALID", "INVALID_PRESSURE_TEMPERATURE", "历史温度或压力读数无效")
|
||||||
|
}
|
||||||
|
if !pExists || !tExists {
|
||||||
|
return fail("MISSING", "INCOMPLETE_PRESSURE_TEMPERATURE", "历史同帧温度或压力缺失")
|
||||||
|
}
|
||||||
|
point.EstimatePressureMPa = &pressure
|
||||||
|
point.EstimateTemperatureC = &temperature
|
||||||
|
point.PressureTemperatureSource = "MAX_SENSOR_AGGREGATE"
|
||||||
|
// Current capacity, current stream state and daily aggregate parameters do
|
||||||
|
// not establish a versioned total tank volume valid at this historical event.
|
||||||
|
return fail("MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", "缺少适用于该历史时点的储氢容积版本证据")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) hydrateHistoricalHydrogenFrame(ctx context.Context, frame *historicalHydrogenFrame) error {
|
||||||
|
var manifest struct {
|
||||||
|
Chunked bool `json:"chunked"`
|
||||||
|
PayloadKind string `json:"payload_kind"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
ChunkCount int `json:"chunk_count"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(frame.Parsed), &manifest) != nil || !manifest.Chunked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if manifest.ChunkCount <= 0 || manifest.ChunkCount > 256 || (manifest.PayloadKind != "parsed_fields" && manifest.PayloadKind != "parsed_json") || (manifest.EventID != "" && manifest.EventID != frame.EventID) {
|
||||||
|
return errors.New("invalid historical hydrogen chunk manifest")
|
||||||
|
}
|
||||||
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||||
|
query := `SELECT chunk_index,chunk_count,chunk_text FROM ` + r.tdDatabase + `.raw_frame_payload_chunks WHERE vin=` + quote(frame.VIN) + ` AND protocol=` + quote(frame.Protocol) + ` AND event_id=` + quote(frame.EventID) + ` AND frame_id=` + quote(frame.FrameID) + ` AND payload_kind=` + quote(manifest.PayloadKind) + ` ORDER BY chunk_index LIMIT ` + strconv.Itoa(manifest.ChunkCount+1)
|
||||||
|
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
parts := make([]string, manifest.ChunkCount)
|
||||||
|
seen := make([]bool, manifest.ChunkCount)
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var index, total int
|
||||||
|
var part string
|
||||||
|
if err := rows.Scan(&index, &total, &part); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(parts) || seen[index] || total != len(parts) {
|
||||||
|
return errors.New("inconsistent historical hydrogen chunks")
|
||||||
|
}
|
||||||
|
seen[index] = true
|
||||||
|
parts[index] = part
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != len(parts) {
|
||||||
|
return errors.New("incomplete historical hydrogen chunks")
|
||||||
|
}
|
||||||
|
frame.Parsed = strings.Join(parts, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a second candidate to detect ambiguous ordering across TDengine child
|
||||||
|
// tables. Never select arbitrarily or skip a tied hydrogen frame when advancing
|
||||||
|
// past a hydrated non-hydrogen candidate.
|
||||||
|
func (r *MySQLRepository) loadHistoricalHydrogenCandidate(ctx context.Context, query string) (historicalHydrogenFrame, error) {
|
||||||
|
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return historicalHydrogenFrame{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var first historicalHydrogenFrame
|
||||||
|
scan := func(frame *historicalHydrogenFrame) error {
|
||||||
|
return rows.Scan(&frame.VIN, &frame.Protocol, &frame.EventID, &frame.FrameID, &frame.EventMS, &frame.ReceivedMS, &frame.TimestampMS, &frame.Parsed, &frame.ParseStatus)
|
||||||
|
}
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return first, err
|
||||||
|
}
|
||||||
|
return first, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if err := scan(&first); err != nil {
|
||||||
|
return first, err
|
||||||
|
}
|
||||||
|
if rows.Next() {
|
||||||
|
var second historicalHydrogenFrame
|
||||||
|
if err := scan(&second); err != nil {
|
||||||
|
return first, err
|
||||||
|
}
|
||||||
|
if first.EventMS == second.EventMS && first.TimestampMS == second.TimestampMS {
|
||||||
|
return first, errors.New("ambiguous historical hydrogen records at identical collection and storage timestamps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return first, err
|
||||||
|
}
|
||||||
|
return first, nil
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func historicalTestFrame(parsed string) historicalHydrogenFrame {
|
||||||
|
return historicalHydrogenFrame{VIN: "V1", Protocol: "GB32960", EventID: "E1", FrameID: "F1", Parsed: parsed, ParseStatus: "OK", EventMS: sql.NullInt64{Int64: HistoricalHydrogenStart.Add(time.Hour).UnixMilli(), Valid: true}, ReceivedMS: sql.NullInt64{Int64: HistoricalHydrogenStart.Add(2 * time.Hour).UnixMilli(), Valid: true}}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenRawPrecisionAndInvalidPriority(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
parsed, status, reason string
|
||||||
|
mass *float64
|
||||||
|
}{
|
||||||
|
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "NORMAL", "", realLiveFloat(0)},
|
||||||
|
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":32.658123456}`, "NORMAL", "", realLiveFloat(32.658123456)},
|
||||||
|
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":-1}`, "INVALID", "INVALID_MASS_READING", nil},
|
||||||
|
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gd_fc_vehicle_hydrogen_mass_kg":10}`, "INVALID", "INVALID_MASS_READING", nil},
|
||||||
|
{fmtRealtimePT(35, 15), "MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", nil},
|
||||||
|
{fmtRealtimePT(0, 15), "MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", nil},
|
||||||
|
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null}`, "INVALID", "INVALID_PRESSURE_TEMPERATURE", nil},
|
||||||
|
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, "MISSING", "INCOMPLETE_PRESSURE_TEMPERATURE", nil},
|
||||||
|
} {
|
||||||
|
got := historicalHydrogenFromFrame(historicalTestFrame(tc.parsed))
|
||||||
|
if got.Status != tc.status || got.ReasonCode != tc.reason || (got.RemainingHydrogenKg == nil) != (tc.mass == nil) || tc.mass != nil && *got.RemainingHydrogenKg != *tc.mass {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame := historicalTestFrame(`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":1}`)
|
||||||
|
first := historicalHydrogenFromFrame(frame)
|
||||||
|
frame.Parsed = `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":2}`
|
||||||
|
second := historicalHydrogenFromFrame(frame)
|
||||||
|
if first.RecordID != second.RecordID || first.SourceDataVersion == second.SourceDataVersion || first.UpdatedAt != second.UpdatedAt {
|
||||||
|
t.Fatal("revision tracking invalid")
|
||||||
|
}
|
||||||
|
frame.ParseStatus = "ERROR"
|
||||||
|
if got := historicalHydrogenFromFrame(frame); got.Status != "INVALID" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenQueryEventTimeAndSafeBounds(t *testing.T) {
|
||||||
|
q, err := historicalHydrogenQuery("vehicle_ts", "VIN'1", HistoricalHydrogenStart.Add(time.Hour), "GB32960")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"vin='VIN''1'", "event_time>=", "event_time<=", "ORDER BY event_time DESC,ts DESC LIMIT 2", `"chunked":true`} {
|
||||||
|
if !strings.Contains(q, want) {
|
||||||
|
t.Fatal(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"parse_status='OK'", "ts<=", "IS NOT NULL"} {
|
||||||
|
if strings.Contains(q, bad) {
|
||||||
|
t.Fatal(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := historicalHydrogenQuery("bad;sql", "V1", HistoricalHydrogenStart, "GB32960"); err == nil {
|
||||||
|
t.Fatal("unsafe db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenRepositoryPreservesDelayedUpload(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.Add(time.Hour).UnixMilli(), at.Add(time.Hour).UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "OK"))
|
||||||
|
got, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960")
|
||||||
|
if err != nil || got == nil || got.Status != "NORMAL" || !got.ReceivedAt.After(at) {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenChunkMissingFailsInsteadOfFallback(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
frame := historicalTestFrame(`{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":2}`)
|
||||||
|
mock.ExpectQuery("SELECT chunk_index,chunk_count,chunk_text").WillReturnRows(sqlmock.NewRows([]string{"index", "count", "text"}).AddRow(0, 2, `{"x":`))
|
||||||
|
if err := r.hydrateHistoricalHydrogenFrame(context.Background(), &frame); err == nil {
|
||||||
|
t.Fatal("missing chunk silently accepted")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenChunkHydration(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
frame := historicalTestFrame(`{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":2}`)
|
||||||
|
mock.ExpectQuery("SELECT chunk_index,chunk_count,chunk_text").WillReturnRows(sqlmock.NewRows([]string{"index", "count", "text"}).AddRow(0, 2, `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":`).AddRow(1, 2, `0}`))
|
||||||
|
if err := r.hydrateHistoricalHydrogenFrame(context.Background(), &frame); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := historicalHydrogenFromFrame(frame)
|
||||||
|
if got.Status != "NORMAL" || *got.RemainingHydrogenKg != 0 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenNewestInvalidNeverQueriesOlder(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, "OK"))
|
||||||
|
got, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960")
|
||||||
|
if err != nil || got == nil || got.Status != "INVALID" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenRejectsFutureReturnedRow(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.Add(time.Second).UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":10}`, "OK"))
|
||||||
|
if _, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960"); err == nil {
|
||||||
|
t.Fatal("future row accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenTiedCandidatesFailWithoutSkipping(t *testing.T) {
|
||||||
|
for _, parsed := range []string{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":10}`, `{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":1}`} {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), parsed, "OK").AddRow("V1", "GB32960", "E2", "F2", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":20}`, "OK"))
|
||||||
|
if _, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||||
|
t.Fatalf("expected ambiguity, got %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
td.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
const historicalTestVIN = "LA9GG68L2PBAF4773"
|
||||||
|
const historicalTestKey = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
type historicalTestRepository struct {
|
||||||
|
*fakeRepository
|
||||||
|
lookup func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error)
|
||||||
|
allowed func(string, time.Time, time.Time) (bool, error)
|
||||||
|
lookups atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *historicalTestRepository) HistoricalHydrogen(ctx context.Context, vin string, at time.Time, protocol string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
r.lookups.Add(1)
|
||||||
|
if r.lookup != nil {
|
||||||
|
return r.lookup(ctx, vin, at, protocol)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *historicalTestRepository) HistoricalHydrogenAuthorized(_ context.Context, _ uint64, vin string, at, now time.Time) (bool, error) {
|
||||||
|
if r.allowed != nil {
|
||||||
|
return r.allowed(vin, at, now)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func historicalTestService() (*Service, *historicalTestRepository, time.Time) {
|
||||||
|
repository := &historicalTestRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 9}}}
|
||||||
|
service := NewService(repository)
|
||||||
|
now := time.Date(2026, 9, 9, 12, 0, 0, 0, service.location)
|
||||||
|
service.now = func() time.Time { return now }
|
||||||
|
return service, repository, now
|
||||||
|
}
|
||||||
|
func historicalTestRequest(at time.Time) HistoricalHydrogenRequest {
|
||||||
|
return HistoricalHydrogenRequest{Queries: []HistoricalHydrogenQuery{{RequestID: "start", VIN: historicalTestVIN, Time: at.Format("2006-01-02 15:04:05")}}}
|
||||||
|
}
|
||||||
|
func historicalTestPoint(at time.Time, kg float64) *HistoricalHydrogenPoint {
|
||||||
|
return &HistoricalHydrogenPoint{VIN: historicalTestVIN, Protocol: "GB32960", ObservedAt: at, RemainingHydrogenKg: &kg, Status: "NORMAL", ValueSource: "REPORTED", RecordID: "record", SourceDataVersion: "data-v1", CalculationVersion: "decode-v1", UpdatedAt: at}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenValidation(t *testing.T) {
|
||||||
|
service, _, now := historicalTestService()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*HistoricalHydrogenRequest)
|
||||||
|
}{
|
||||||
|
{"empty", func(r *HistoricalHydrogenRequest) { r.Queries = nil }},
|
||||||
|
{"too many", func(r *HistoricalHydrogenRequest) { r.Queries = make([]HistoricalHydrogenQuery, 201) }},
|
||||||
|
{"duplicate id", func(r *HistoricalHydrogenRequest) { r.Queries = append(r.Queries, r.Queries[0]) }},
|
||||||
|
{"blank id", func(r *HistoricalHydrogenRequest) { r.Queries[0].RequestID = " " }},
|
||||||
|
{"bad VIN", func(r *HistoricalHydrogenRequest) { r.Queries[0].VIN = "LA9GG68L2PBAI4773" }},
|
||||||
|
{"fractional time", func(r *HistoricalHydrogenRequest) { r.Queries[0].Time = "2026-09-08 12:00:00.1" }},
|
||||||
|
{"future", func(r *HistoricalHydrogenRequest) {
|
||||||
|
r.Queries[0].Time = now.Add(time.Second).Format("2006-01-02 15:04:05")
|
||||||
|
}},
|
||||||
|
{"before coverage", func(r *HistoricalHydrogenRequest) { r.Queries[0].Time = "2026-07-31 23:59:59" }},
|
||||||
|
{"protocol alias", func(r *HistoricalHydrogenRequest) { r.Queries[0].Protocol = "MQTT" }},
|
||||||
|
{"negative tolerance", func(r *HistoricalHydrogenRequest) { v := -1; r.MaxTimeDifferenceSeconds = &v }},
|
||||||
|
{"too large tolerance", func(r *HistoricalHydrogenRequest) { v := 301; r.MaxTimeDifferenceSeconds = &v }},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
request := historicalTestRequest(now.Add(-time.Hour))
|
||||||
|
tc.mutate(&request)
|
||||||
|
if _, _, err := service.validateHistoricalHydrogen(request, now); !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
request := historicalTestRequest(now)
|
||||||
|
request.Queries[0].RequestID = " original id "
|
||||||
|
parsed, tolerance, err := service.validateHistoricalHydrogen(request, now)
|
||||||
|
if err != nil || tolerance != 300 || parsed[0].Protocol != "GB32960" || parsed[0].RequestID != " original id " {
|
||||||
|
t.Fatalf("%+v %d %v", parsed, tolerance, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenNormalZeroPrecisionStaleAndInvalid(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
kg float64
|
||||||
|
age time.Duration
|
||||||
|
tolerance int
|
||||||
|
status string
|
||||||
|
}{
|
||||||
|
{"zero", 0, 0, 0, "NORMAL"}, {"precision", 12.3456789, time.Second, 300, "NORMAL"}, {"exact tolerance", 2, 300 * time.Second, 300, "NORMAL"}, {"stale", 2, 300*time.Second + time.Millisecond, 300, "STALE"}, {"not exact", 2, time.Millisecond, 0, "STALE"}, {"invalid negative", -1, 0, 300, "INVALID"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
at := now.Add(-time.Hour)
|
||||||
|
repository.lookup = func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
return historicalTestPoint(at.Add(-tc.age), tc.kg), nil
|
||||||
|
}
|
||||||
|
request := historicalTestRequest(at)
|
||||||
|
request.MaxTimeDifferenceSeconds = &tc.tolerance
|
||||||
|
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := results[0]
|
||||||
|
if got.RemainingHydrogenKgStatus != tc.status {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if tc.status == "NORMAL" {
|
||||||
|
if got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != tc.kg {
|
||||||
|
t.Fatal("lost zero or precision")
|
||||||
|
}
|
||||||
|
} else if got.RemainingHydrogenKg != nil {
|
||||||
|
t.Fatal("non-normal quantity exposed")
|
||||||
|
}
|
||||||
|
if got.TimeDifferenceSeconds == nil || *got.TimeDifferenceSeconds != tc.age.Seconds() {
|
||||||
|
t.Fatalf("time delta=%v", got.TimeDifferenceSeconds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenBatchForbiddenNeverLeaksSample(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
at := now.Add(-time.Hour)
|
||||||
|
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
return historicalTestPoint(at.Add(-time.Minute), 4), nil
|
||||||
|
}
|
||||||
|
repository.allowed = func(_ string, sample, _ time.Time) (bool, error) { return !sample.Before(at), nil }
|
||||||
|
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := results[0]
|
||||||
|
if got.RemainingHydrogenKgStatus != "FORBIDDEN" || got.RemainingHydrogenKg != nil || got.HydrogenRecordTime != nil || got.SourceRecordID != nil || got.HydrogenSourceProtocol != nil || got.SourceDataVersion != nil || got.UpdatedAt != nil {
|
||||||
|
t.Fatalf("sample leaked: %+v", got)
|
||||||
|
}
|
||||||
|
repository.allowed = func(string, time.Time, time.Time) (bool, error) { return false, nil }
|
||||||
|
repository.lookups.Store(0)
|
||||||
|
results, err = service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
|
||||||
|
if err != nil || repository.lookups.Load() != 0 || results[0].RemainingHydrogenKgStatus != "FORBIDDEN" {
|
||||||
|
t.Fatalf("queried forbidden data: %+v %v", results, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenMixedBatchAndNoProtocolFallback(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
at := now.Add(-time.Hour)
|
||||||
|
repository.lookup = func(_ context.Context, _ string, sample time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
if sample.Equal(at) {
|
||||||
|
return nil, errors.New("private driver query")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
request := historicalTestRequest(at)
|
||||||
|
request.Queries = append(request.Queries, HistoricalHydrogenQuery{RequestID: "none", VIN: historicalTestVIN, Time: at.Add(-time.Minute).Format("2006-01-02 15:04:05")}, HistoricalHydrogenQuery{RequestID: "unsupported", VIN: historicalTestVIN, Time: at.Format("2006-01-02 15:04:05"), Protocol: "JT808"})
|
||||||
|
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, want := range []string{"ERROR", "NO_DATA", "UNSUPPORTED"} {
|
||||||
|
if results[i].RequestID != request.Queries[i].RequestID || results[i].RemainingHydrogenKgStatus != want {
|
||||||
|
t.Fatalf("%+v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repository.lookups.Load() != 2 {
|
||||||
|
t.Fatalf("fallback read count=%d", repository.lookups.Load())
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(results)
|
||||||
|
if strings.Contains(string(encoded), "private") {
|
||||||
|
t.Fatal("driver detail leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenRejectsFutureOrWrongIdentitySample(t *testing.T) {
|
||||||
|
for _, kind := range []string{"future", "vin", "protocol"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
at := now.Add(-time.Hour)
|
||||||
|
repository.lookup = func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
point := historicalTestPoint(at, 4)
|
||||||
|
switch kind {
|
||||||
|
case "future":
|
||||||
|
point.ObservedAt = at.Add(time.Second)
|
||||||
|
case "vin":
|
||||||
|
point.VIN = "OTHER"
|
||||||
|
case "protocol":
|
||||||
|
point.Protocol = "JT808"
|
||||||
|
}
|
||||||
|
return point, nil
|
||||||
|
}
|
||||||
|
result, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
|
||||||
|
if err != nil || result[0].RemainingHydrogenKgStatus != "ERROR" || result[0].SourceRecordID != nil {
|
||||||
|
t.Fatalf("%+v %v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenDeadlineKeepsEveryRequestID(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
repository.lookup = func(ctx context.Context, _ string, _ time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
request := historicalTestRequest(now.Add(-time.Hour))
|
||||||
|
for i := 1; i < 8; i++ {
|
||||||
|
query := request.Queries[0]
|
||||||
|
query.RequestID = string(rune('a' + i))
|
||||||
|
request.Queries = append(request.Queries, query)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
results, err := service.QueryHistoricalHydrogen(ctx, historicalTestKey, "trace", request)
|
||||||
|
if err != nil || len(results) != 8 {
|
||||||
|
t.Fatalf("%+v %v", results, err)
|
||||||
|
}
|
||||||
|
for i, got := range results {
|
||||||
|
if got.RequestID != request.Queries[i].RequestID || got.RemainingHydrogenKgStatus != "ERROR" || got.RemainingHydrogenKg != nil {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repository.lookups.Load() > 4 {
|
||||||
|
t.Fatal("more than four workers queried")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenHandlerAuthenticationAndRateLimit(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
handler := NewDataHandler(service)
|
||||||
|
request := historicalTestRequest(now.Add(-time.Hour))
|
||||||
|
request.Queries[0].Protocol = "JT808"
|
||||||
|
body, _ := json.Marshal(request)
|
||||||
|
call := func() *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, HistoricalHydrogenQueryPath, strings.NewReader(string(body)))
|
||||||
|
r.Header.Set("Authorization", "Bearer "+historicalTestKey)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
repository.authErr = ErrUnauthorized
|
||||||
|
if w := call(); w.Code != 401 {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
repository.authErr = nil
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if w := call(); w.Code != 200 {
|
||||||
|
t.Fatalf("request %d status=%d body=%s", i, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := call()
|
||||||
|
if w.Code != 429 || w.Header().Get("Retry-After") != "60" || !strings.Contains(w.Body.String(), "RATE_LIMITED") {
|
||||||
|
t.Fatalf("status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String())
|
||||||
|
}
|
||||||
|
if !IsPublicPath(HistoricalHydrogenQueryPath) {
|
||||||
|
t.Fatal("missing public route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenConcurrentLimitAndWindowReset(t *testing.T) {
|
||||||
|
var limiter historicalHydrogenLimiter
|
||||||
|
now := time.Now()
|
||||||
|
release1, err := limiter.acquire(1, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release2, err := limiter.acquire(1, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := limiter.acquire(1, now); err == nil {
|
||||||
|
t.Fatal("third concurrent batch allowed")
|
||||||
|
}
|
||||||
|
release1()
|
||||||
|
release2()
|
||||||
|
for i := 2; i < 30; i++ {
|
||||||
|
release, err := limiter.acquire(1, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
if _, err := limiter.acquire(1, now); err == nil {
|
||||||
|
t.Fatal("31st batch allowed")
|
||||||
|
}
|
||||||
|
release, err := limiter.acquire(1, now.Add(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
func TestHistoricalHydrogenAuthorizationChecksAppAndGrantAtBothTimes(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
now := time.Now()
|
||||||
|
at := now.Add(-time.Hour)
|
||||||
|
mock.ExpectQuery("SELECT EXISTS.*a.valid_to>\\?.*a.valid_to>\\?.*BINARY g.vin=BINARY \\?.*g.valid_to>\\?").WithArgs(uint64(9), now, now, at, at, historicalTestVIN, at, at).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||||
|
allowed, err := NewMySQLRepository(db).HistoricalHydrogenAuthorized(context.Background(), 9, historicalTestVIN, at, now)
|
||||||
|
if err != nil || allowed {
|
||||||
|
t.Fatalf("allowed=%v err=%v", allowed, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenTwoHundredItemsShareEighteenLookups(t *testing.T) {
|
||||||
|
service, repository, now := historicalTestService()
|
||||||
|
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||||
|
return historicalTestPoint(at, 12.3456789), nil
|
||||||
|
}
|
||||||
|
var request HistoricalHydrogenRequest
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
request.Queries = append(request.Queries, HistoricalHydrogenQuery{RequestID: strconv.Itoa(i), VIN: historicalTestVIN, Time: now.Add(-time.Duration(i%18) * time.Minute).Format("2006-01-02 15:04:05")})
|
||||||
|
}
|
||||||
|
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
|
||||||
|
if err != nil || len(results) != 200 || repository.lookups.Load() != 18 {
|
||||||
|
t.Fatalf("len=%d lookups=%d err=%v", len(results), repository.lookups.Load(), err)
|
||||||
|
}
|
||||||
|
for i, got := range results {
|
||||||
|
if got.RequestID != strconv.Itoa(i) || got.QueryTime != request.Queries[i].Time || got.RemainingHydrogenKgStatus != "NORMAL" || got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 12.3456789 {
|
||||||
|
t.Fatalf("index=%d result=%+v", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalHydrogenDecoderRejectsNullAndUnknownFields(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00"}],"maxTimeDifferenceSeconds":null}`,
|
||||||
|
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00","protocol":null}]}`,
|
||||||
|
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00","unexpected":true}]}`,
|
||||||
|
`{"queries":[],"unexpected":true}`,
|
||||||
|
} {
|
||||||
|
var request HistoricalHydrogenRequest
|
||||||
|
if err := json.Unmarshal([]byte(body), &request); err == nil {
|
||||||
|
t.Fatalf("accepted %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReconciledMileageRange uses terminal odometers, including one preceding
|
||||||
|
// observation per protocol. 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 {
|
||||||
|
return map[string]DailyMileage{}, nil
|
||||||
|
}
|
||||||
|
if len(protocols) == 0 {
|
||||||
|
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
|
||||||
|
}
|
||||||
|
placeholders := func(n int) string { return strings.TrimRight(strings.Repeat("?,", n), ",") }
|
||||||
|
// Unknown-protocol legacy imports and GPS distance are not terminal odometers.
|
||||||
|
eligible := func(alias string) string {
|
||||||
|
return fmt.Sprintf(`%[1]s.quality_status IN ('OK','INVALID_DELTA')
|
||||||
|
AND %[1]s.latest_total_mileage_km > 0
|
||||||
|
AND COALESCE(%[1]s.quality_reason,'') <> 'gps_coordinate_accumulation'
|
||||||
|
AND %[1]s.source_ip NOT IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')
|
||||||
|
AND %[1]s.latest_event_time IS NOT NULL
|
||||||
|
AND %[1]s.latest_event_time < TIMESTAMP(%[1]s.stat_date)+INTERVAL 1 DAY`, alias)
|
||||||
|
}
|
||||||
|
filter := `vin IN (` + placeholders(len(vins)) + `) AND protocol IN (` + placeholders(len(protocols)) + `)`
|
||||||
|
query := `WITH wanted AS (
|
||||||
|
SELECT DISTINCT vin,protocol,stat_date FROM vehicle_daily_mileage_source p
|
||||||
|
WHERE ` + filter + ` AND stat_date BETWEEN ? AND ? AND ` + eligible("p") + `
|
||||||
|
UNION ALL
|
||||||
|
SELECT vin,protocol,MAX(stat_date) AS stat_date FROM vehicle_daily_mileage_source p
|
||||||
|
WHERE ` + filter + ` AND stat_date<? AND ` + eligible("p") + ` GROUP BY vin,protocol
|
||||||
|
)
|
||||||
|
SELECT s.vin,DATE_FORMAT(s.stat_date,'%Y-%m-%d'),s.protocol,s.source_key,
|
||||||
|
s.daily_mileage_km,s.latest_total_mileage_km,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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`
|
||||||
|
args := make([]any, 0, 2*(len(vins)+len(protocols))+3)
|
||||||
|
for _, v := range vins {
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
for _, p := range protocols {
|
||||||
|
args = append(args, p)
|
||||||
|
}
|
||||||
|
args = append(args, start, end)
|
||||||
|
for _, v := range vins {
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
for _, p := range protocols {
|
||||||
|
args = append(args, p)
|
||||||
|
}
|
||||||
|
args = append(args, start)
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var points []DailyMileage
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := v.VIN + "|" + v.Date + "|" + v.Protocol
|
||||||
|
if !seen[key] {
|
||||||
|
points = append(points, v)
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reconcileMileage(points, vins, start, end, protocols)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileMileage(points []DailyMileage, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
first, err := time.ParseInLocation("2006-01-02", start, loc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
last, err := time.ParseInLocation("2006-01-02", end, loc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if last.Before(first) {
|
||||||
|
return nil, fmt.Errorf("invalid mileage interval")
|
||||||
|
}
|
||||||
|
if len(protocols) == 0 {
|
||||||
|
protocols = []string{"GB32960", "YUTONG_MQTT", "JT808"}
|
||||||
|
}
|
||||||
|
sort.SliceStable(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||||||
|
state := map[string]map[string]DailyMileage{}
|
||||||
|
pick := func(vin string) (DailyMileage, bool) {
|
||||||
|
for _, p := range protocols {
|
||||||
|
if v, ok := state[vin][p]; ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DailyMileage{}, false
|
||||||
|
}
|
||||||
|
put := func(v DailyMileage) {
|
||||||
|
if state[v.VIN] == nil {
|
||||||
|
state[v.VIN] = map[string]DailyMileage{}
|
||||||
|
}
|
||||||
|
state[v.VIN][v.Protocol] = v
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
for i < len(points) && points[i].Date < start {
|
||||||
|
put(points[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out := map[string]DailyMileage{}
|
||||||
|
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
|
||||||
|
date := day.Format("2006-01-02")
|
||||||
|
before := map[string]DailyMileage{}
|
||||||
|
for _, vin := range vins {
|
||||||
|
if p, ok := pick(vin); ok {
|
||||||
|
before[vin] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i < len(points) && points[i].Date == date {
|
||||||
|
put(points[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
for _, vin := range vins {
|
||||||
|
current, ok := pick(vin)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result := current
|
||||||
|
previous, hasPrevious := before[vin]
|
||||||
|
if current.Date < date {
|
||||||
|
result.MileageKm = 0
|
||||||
|
result.StatisticsStartTime = ""
|
||||||
|
if result.DataQuality == "" {
|
||||||
|
result.DataQuality = "CARRIED_FORWARD"
|
||||||
|
}
|
||||||
|
} else if current.DataQuality != "" {
|
||||||
|
result.MileageKm = 0
|
||||||
|
} else if !hasPrevious {
|
||||||
|
result.DataQuality = "NO_PREVIOUS_BASELINE"
|
||||||
|
} else if current.Protocol != previous.Protocol || current.SourceKey != previous.SourceKey {
|
||||||
|
result.DataQuality = "ODOMETER_SOURCE_CHANGED"
|
||||||
|
} else if current.TotalMileageKm < previous.TotalMileageKm {
|
||||||
|
result.DataQuality = mileageTotalRollbackQuality
|
||||||
|
} else if previous.DataQuality != "" {
|
||||||
|
result.DataQuality = "PREVIOUS_ODOMETER_ANOMALY"
|
||||||
|
} else {
|
||||||
|
result.MileageKm = math.Round((current.TotalMileageKm-previous.TotalMileageKm)*1000) / 1000
|
||||||
|
result.StatisticsStartTime = previous.DataTime
|
||||||
|
}
|
||||||
|
out[dailyMileageKey(vin, date)] = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReconciliationRecoveryAndPagination(t *testing.T) {
|
||||||
|
points := []DailyMileage{
|
||||||
|
{VIN: "v", Date: "2026-09-13", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 14366, DataTime: "2026-09-13T07:17:27+08:00"},
|
||||||
|
{VIN: "v", Date: "2026-09-15", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 14467.4, DataTime: "2026-09-15T20:57:04+08:00"},
|
||||||
|
}
|
||||||
|
all, err := reconcileMileage(points, []string{"v"}, "2026-09-14", "2026-09-15", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if all["v\x002026-09-15"].MileageKm != 101.4 {
|
||||||
|
t.Fatalf("recovery=%+v", all)
|
||||||
|
}
|
||||||
|
for _, date := range []string{"2026-09-14", "2026-09-15"} {
|
||||||
|
one, _ := reconcileMileage(points, []string{"v"}, date, date, nil)
|
||||||
|
if !reflect.DeepEqual(one[dailyMileageKey("v", date)], all[dailyMileageKey("v", date)]) {
|
||||||
|
t.Fatal("page changes result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestReconciliationPinsProtocolThroughMissingDays(t *testing.T) {
|
||||||
|
points := []DailyMileage{{VIN: "v", Date: "2026-09-11", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 100}, {VIN: "v", Date: "2026-09-12", Protocol: "YUTONG_MQTT", SourceKey: "b", TotalMileageKm: 9000}, {VIN: "v", Date: "2026-09-14", Protocol: "GB32960", SourceKey: "a", TotalMileageKm: 120}}
|
||||||
|
got, _ := reconcileMileage(points, []string{"v"}, "2026-09-12", "2026-09-14", nil)
|
||||||
|
if got[dailyMileageKey("v", "2026-09-12")].TotalMileageKm != 100 || got[dailyMileageKey("v", "2026-09-14")].MileageKm != 20 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestReconciliationMarksDiscontinuities(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, source string
|
||||||
|
total float64
|
||||||
|
want string
|
||||||
|
}{{"rollback", "a", 90, mileageTotalRollbackQuality}, {"replacement", "b", 120, "ODOMETER_SOURCE_CHANGED"}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, _ := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "a", TotalMileageKm: 100}, {VIN: "v", Date: "2026-09-13", Protocol: "JT808", SourceKey: tc.source, TotalMileageKm: tc.total}}, []string{"v"}, "2026-09-13", "2026-09-13", []string{"JT808"})
|
||||||
|
v := got[dailyMileageKey("v", "2026-09-13")]
|
||||||
|
var response MileageResult
|
||||||
|
fillMileageResult(&response, v, v.MileageKm)
|
||||||
|
if response.DailyMileageKm != nil || response.Status != StatusDataAnomaly || v.DataQuality != tc.want {
|
||||||
|
t.Fatalf("%+v %+v", v, response)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconciliationFirstObservationHasNoInventedDistance(t *testing.T) {
|
||||||
|
got, _ := reconcileMileage([]DailyMileage{{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "a", TotalMileageKm: 100, DataTime: "2026-09-12T10:00:00+08:00"}}, []string{"v"}, "2026-09-12", "2026-09-12", nil)
|
||||||
|
v := got[dailyMileageKey("v", "2026-09-12")]
|
||||||
|
var result MileageResult
|
||||||
|
fillMileageResult(&result, v, v.MileageKm)
|
||||||
|
if result.DailyMileageKm != nil || result.TotalMileageKm == nil || *result.DataQuality != "NO_PREVIOUS_BASELINE" {
|
||||||
|
t.Fatalf("%+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source replacement is flagged only on its boundary; starting a new page
|
||||||
|
// must not hide that boundary or change the following day's odometer delta.
|
||||||
|
func TestReconciliationSourceReplacementAcrossPageBoundary(t *testing.T) {
|
||||||
|
points := []DailyMileage{
|
||||||
|
{VIN: "v", Date: "2026-09-11", Protocol: "JT808", SourceKey: "old", TotalMileageKm: 9000},
|
||||||
|
{VIN: "v", Date: "2026-09-12", Protocol: "JT808", SourceKey: "new", TotalMileageKm: 100},
|
||||||
|
{VIN: "v", Date: "2026-09-14", Protocol: "JT808", SourceKey: "new", TotalMileageKm: 140},
|
||||||
|
}
|
||||||
|
all, _ := reconcileMileage(points, []string{"v"}, "2026-09-12", "2026-09-14", nil)
|
||||||
|
if all[dailyMileageKey("v", "2026-09-12")].DataQuality != "ODOMETER_SOURCE_CHANGED" {
|
||||||
|
t.Fatal(all)
|
||||||
|
}
|
||||||
|
if all[dailyMileageKey("v", "2026-09-14")].MileageKm != 40 {
|
||||||
|
t.Fatal(all)
|
||||||
|
}
|
||||||
|
for _, day := range []string{"2026-09-12", "2026-09-13", "2026-09-14"} {
|
||||||
|
one, _ := reconcileMileage(points, []string{"v"}, day, day, nil)
|
||||||
|
if !reflect.DeepEqual(one[dailyMileageKey("v", day)], all[dailyMileageKey("v", day)]) {
|
||||||
|
t.Fatal(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,10 @@ func (p *ProtocolPriority) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HydrogenResult struct {
|
type HydrogenResult struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
StatisticsStartTime *string `json:"statisticsStartTime"`
|
||||||
|
StatisticsEndTime *string `json:"statisticsEndTime"`
|
||||||
|
UpdatedAt *string `json:"updatedAt"`
|
||||||
PlateNumber string `json:"plateNumber"`
|
PlateNumber string `json:"plateNumber"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||||
@@ -57,16 +61,18 @@ type HydrogenResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MileageResult struct {
|
type MileageResult struct {
|
||||||
VIN string `json:"vin"`
|
StatisticsStartTime *string `json:"statisticsStartTime"`
|
||||||
PlateNumber string `json:"plateNumber"`
|
StatisticsEndTime *string `json:"statisticsEndTime"`
|
||||||
Date string `json:"date"`
|
VIN string `json:"vin"`
|
||||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
PlateNumber string `json:"plateNumber"`
|
||||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
Date string `json:"date"`
|
||||||
DataTime *string `json:"dataTime"`
|
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||||
UpdatedAt *string `json:"updatedAt"`
|
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||||
SourceProtocol *string `json:"sourceProtocol"`
|
DataTime *string `json:"dataTime"`
|
||||||
DataQuality *string `json:"dataQuality,omitempty"`
|
UpdatedAt *string `json:"updatedAt"`
|
||||||
Status string `json:"status"`
|
SourceProtocol *string `json:"sourceProtocol"`
|
||||||
|
DataQuality *string `json:"dataQuality,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MileageRangeResult struct {
|
type MileageRangeResult struct {
|
||||||
@@ -151,21 +157,31 @@ type RealtimeVehicleRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RealtimeVehiclePoint struct {
|
type RealtimeVehiclePoint struct {
|
||||||
VIN string
|
LiveHydrogen RealtimeHydrogenData
|
||||||
Protocol string
|
LocationObservedAt time.Time
|
||||||
Longitude float64
|
LocationEventID string
|
||||||
Latitude float64
|
LocationReceivedAt time.Time
|
||||||
SpeedKmh float64
|
GPSFixStatus string
|
||||||
SOCPercent *float64
|
CoordinateSystem string
|
||||||
TotalMileageKm float64
|
VIN string
|
||||||
ObservedAt time.Time
|
Protocol string
|
||||||
Online bool
|
Longitude float64
|
||||||
ActiveToday bool
|
Latitude float64
|
||||||
|
SpeedKmh float64
|
||||||
|
SOCPercent *float64
|
||||||
|
TotalMileageKm float64
|
||||||
|
ObservedAt time.Time
|
||||||
|
Online bool
|
||||||
|
ActiveToday bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type RealtimeVehicleResult struct {
|
type RealtimeVehicleResult struct {
|
||||||
VIN string `json:"vin"`
|
RealtimeHydrogenData
|
||||||
PlateNumber string `json:"plateNumber"`
|
GPSFixStatus string `json:"gpsFixStatus"`
|
||||||
|
LocationRecordTime *string `json:"locationRecordTime"`
|
||||||
|
CoordinateSystem string `json:"coordinateSystem"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
PlateNumber string `json:"plateNumber"`
|
||||||
// Protocol is the single, canonical source protocol for this realtime record.
|
// Protocol is the single, canonical source protocol for this realtime record.
|
||||||
// It is normalized to GB32960, MQTT, or JT808.
|
// It is normalized to GB32960, MQTT, or JT808.
|
||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
@@ -285,6 +301,8 @@ type AppCredential struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DailyHydrogen struct {
|
type DailyHydrogen struct {
|
||||||
|
EvidenceJSON string
|
||||||
|
UpdatedAt string
|
||||||
VIN string
|
VIN string
|
||||||
Date string
|
Date string
|
||||||
ConsumptionKg float64
|
ConsumptionKg float64
|
||||||
@@ -296,13 +314,16 @@ type DailyHydrogen struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DailyMileage struct {
|
type DailyMileage struct {
|
||||||
VIN string
|
SourceKey string
|
||||||
Date string
|
DataQuality string
|
||||||
Protocol string
|
StatisticsStartTime string
|
||||||
MileageKm float64
|
VIN string
|
||||||
TotalMileageKm float64
|
Date string
|
||||||
DataTime string
|
Protocol string
|
||||||
UpdatedAt string
|
MileageKm float64
|
||||||
|
TotalMileageKm float64
|
||||||
|
DataTime string
|
||||||
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MileageSnapshot struct {
|
type MileageSnapshot struct {
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n
|
|||||||
query := `
|
query := `
|
||||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||||
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
|
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
|
||||||
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today
|
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today,
|
||||||
|
l.event_time,l.received_at,l.event_id
|
||||||
FROM vehicle_realtime_location l
|
FROM vehicle_realtime_location l
|
||||||
WHERE BINARY l.vin IN (` + placeholders + `)
|
WHERE BINARY l.vin IN (` + placeholders + `)
|
||||||
ORDER BY l.vin,
|
ORDER BY l.vin,
|
||||||
@@ -212,9 +213,12 @@ ORDER BY l.vin,
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var point RealtimeVehiclePoint
|
var point RealtimeVehiclePoint
|
||||||
var soc sql.NullFloat64
|
var soc sql.NullFloat64
|
||||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday); err != nil {
|
var locationAt, receivedAt sql.NullTime
|
||||||
|
var eventID sql.NullString
|
||||||
|
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String
|
||||||
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
|
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
|
||||||
value := round3(soc.Float64)
|
value := round3(soc.Float64)
|
||||||
point.SOCPercent = &value
|
point.SOCPercent = &value
|
||||||
@@ -229,7 +233,16 @@ ORDER BY l.vin,
|
|||||||
out[point.VIN] = selected
|
out[point.VIN] = selected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.enrichRealtimeLiveData(ctx, vins, out, now); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||||
@@ -313,7 +326,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
|
|||||||
}
|
}
|
||||||
query, args := inQuery(`
|
query, args := inQuery(`
|
||||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,quality_reason,
|
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,quality_reason,
|
||||||
calculation_phase,algorithm_version
|
calculation_phase,algorithm_version,COALESCE(evidence_json,'null'),
|
||||||
|
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%f+08:00')
|
||||||
FROM vehicle_open_daily_energy
|
FROM vehicle_open_daily_energy
|
||||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
@@ -324,7 +338,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
|||||||
out := make(map[string]DailyHydrogen, len(vins))
|
out := make(map[string]DailyHydrogen, len(vins))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var value DailyHydrogen
|
var value DailyHydrogen
|
||||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
|
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion, &value.EvidenceJSON, &value.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[value.VIN] = value
|
out[value.VIN] = value
|
||||||
@@ -407,7 +421,12 @@ SELECT
|
|||||||
AND selected.is_selected=1
|
AND selected.is_selected=1
|
||||||
AND selected.latest_event_time IS NOT NULL
|
AND selected.latest_event_time IS NOT NULL
|
||||||
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
|
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00'),
|
||||||
|
COALESCE(DATE_FORMAT((SELECT MIN(selected.first_event_time)
|
||||||
|
FROM vehicle_daily_mileage_source selected
|
||||||
|
WHERE selected.vin=m.vin AND selected.stat_date=m.stat_date
|
||||||
|
AND selected.protocol=m.protocol AND selected.is_selected=1
|
||||||
|
),'%Y-%m-%dT%H:%i:%s+08:00'),'')
|
||||||
FROM vehicle_daily_mileage m
|
FROM vehicle_daily_mileage m
|
||||||
WHERE m.stat_date BETWEEN ? AND ?
|
WHERE m.stat_date BETWEEN ? AND ?
|
||||||
AND m.vin IN (` + placeholders + `)
|
AND m.vin IN (` + placeholders + `)
|
||||||
@@ -445,7 +464,7 @@ ORDER BY m.stat_date,m.vin,
|
|||||||
out := make(map[string]DailyMileage, len(vins))
|
out := make(map[string]DailyMileage, len(vins))
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var value DailyMileage
|
var value DailyMileage
|
||||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
|
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt, &value.StatisticsStartTime); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
key := dailyMileageKey(value.VIN, value.Date)
|
key := dailyMileageKey(value.VIN, value.Date)
|
||||||
@@ -805,7 +824,8 @@ func inQuery(template, first string, values []string) (string, []any) {
|
|||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
args = append(args, value)
|
args = append(args, value)
|
||||||
}
|
}
|
||||||
return strings.Replace(template, "%s", placeholders, 1), args
|
// Match the IN marker, not MySQL DATE_FORMAT seconds (%s).
|
||||||
|
return strings.Replace(template, "IN (%s)", "IN ("+placeholders+")", 1), args
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullableTime(value *time.Time) any {
|
func nullableTime(value *time.Time) any {
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
|
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
|
||||||
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%f+08:00') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
||||||
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
|
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
|
||||||
t.Fatalf("date format was corrupted: %s", query)
|
t.Fatalf("date format was corrupted: %s", query)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(query, "%H:%i:%s.%f+08:00") || strings.Contains(query, "IN (%s)") {
|
||||||
|
t.Fatalf("timestamp seconds consumed as VIN placeholder: %s", query)
|
||||||
|
}
|
||||||
if !strings.Contains(query, "vin IN (?,?)") {
|
if !strings.Contains(query, "vin IN (?,?)") {
|
||||||
t.Fatalf("VIN placeholders missing: %s", query)
|
t.Fatalf("VIN placeholders missing: %s", query)
|
||||||
}
|
}
|
||||||
@@ -132,8 +135,8 @@ func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0").
|
mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0").
|
||||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at", "statistics_start_time"}).
|
||||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00", "2026-07-21T00:01:00+08:00"))
|
||||||
|
|
||||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
|
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,9 +162,9 @@ func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *tes
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
|
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
|
||||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
|
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at", "statistics_start_time"}).
|
||||||
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00").
|
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00", "2026-07-21T00:01:00+08:00").
|
||||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00"))
|
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00", "2026-07-21T00:01:00+08:00"))
|
||||||
|
|
||||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
|
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -269,9 +272,9 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T)
|
|||||||
vin := "LTEST32960VIN0001"
|
vin := "LTEST32960VIN0001"
|
||||||
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
||||||
WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
|
WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today"}).
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today", "event_time", "received_at", "event_id"}).
|
||||||
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true).
|
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true, now.Add(-2*time.Minute), now.Add(-2*time.Minute), "gb-frame").
|
||||||
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true))
|
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true, now.Add(-20*time.Second), now.Add(-20*time.Second), "jt-frame"))
|
||||||
|
|
||||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const realtimeHydrogenPressureField = "gb32960.fuel_cell.max_hydrogen_pressure_mpa"
|
||||||
|
const realtimeHydrogenTemperatureField = "gb32960.fuel_cell.max_hydrogen_temperature_c"
|
||||||
|
|
||||||
|
// Capacity is the configured total water volume for this VIN, never a default
|
||||||
|
// inferred from a vehicle model or a partial cylinder reading.
|
||||||
|
func (r *MySQLRepository) loadRealtimeHydrogenCapacities(ctx context.Context, vins []string) (map[string]float64, error) {
|
||||||
|
result := make(map[string]float64)
|
||||||
|
if len(vins) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
args := make([]any, len(vins))
|
||||||
|
for i, vin := range vins {
|
||||||
|
args[i] = vin
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT vin,tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND BINARY vin IN (`+strings.TrimRight(strings.Repeat("?,", len(vins)), ",")+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var vin string
|
||||||
|
var capacity float64
|
||||||
|
if err := rows.Scan(&vin, &capacity); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if capacity > 0 && capacity <= 10000 && !math.IsNaN(capacity) && !math.IsInf(capacity, 0) {
|
||||||
|
result[vin] = capacity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeHydrogenWithCapacity(parsed string, observedAt, now time.Time, capacity float64) RealtimeHydrogenData {
|
||||||
|
result := realtimeHydrogenFromFrame(parsed, observedAt, now)
|
||||||
|
reason := func(value string) { result.HydrogenPercentReason = &value }
|
||||||
|
if result.RemainingHydrogenKgStatus == "INVALID" {
|
||||||
|
result.RemainingHydrogenPercentStatus = "INVALID"
|
||||||
|
reason("INVALID_MASS_READING")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
full, capacityOK := PressureHydrogenMassKg(35, 15, capacity)
|
||||||
|
capacityOK = capacityOK && full > 0
|
||||||
|
if capacityOK {
|
||||||
|
pressure, temperature := 35.0, 15.0
|
||||||
|
version, source := "REAL_GAS_35MPA_15C_V1", "vehicle_hydrogen_tank_capacity"
|
||||||
|
result.HydrogenFullCapacityKg = &full
|
||||||
|
result.HydrogenTankCapacityL = &capacity
|
||||||
|
result.HydrogenFullPressureMPa = &pressure
|
||||||
|
result.HydrogenReferenceTemperatureC = &temperature
|
||||||
|
result.HydrogenCalculationVersion = &version
|
||||||
|
result.HydrogenCapacitySource = &source
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var mass float64
|
||||||
|
if result.RemainingHydrogenKg != nil {
|
||||||
|
// Use the same source measurement for numerator and kg, before rounding.
|
||||||
|
for _, key := range hydrogenMassFields {
|
||||||
|
if value, exists := fields[key]; exists {
|
||||||
|
mass, _ = numericValue(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pv, pExists := fields[realtimeHydrogenPressureField]
|
||||||
|
tv, tExists := fields[realtimeHydrogenTemperatureField]
|
||||||
|
if !pExists && !tExists {
|
||||||
|
result.RemainingHydrogenPercentStatus = "MISSING"
|
||||||
|
reason("MISSING_HYDROGEN_MEASUREMENT")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if !observedAt.IsZero() {
|
||||||
|
at := observedAt.In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.RFC3339Nano)
|
||||||
|
result.HydrogenRecordTime = &at
|
||||||
|
}
|
||||||
|
pressure, pOK := numericValue(pv)
|
||||||
|
temperature, tOK := numericValue(tv)
|
||||||
|
// Explicit invalid telemetry blocks fallback. Missing peer fields are also
|
||||||
|
// withheld: readings from different raw frames must never be combined.
|
||||||
|
if (pExists && (!pOK || !finiteRealtime(pressure) || pressure < 0 || pressure > 70)) || (tExists && (!tOK || !finiteRealtime(temperature) || temperature < -40 || temperature > 726.85)) || observedAt.IsZero() || observedAt.After(now.Add(time.Minute)) {
|
||||||
|
result.HydrogenDataStatus = "INVALID"
|
||||||
|
result.RemainingHydrogenKgStatus = "INVALID"
|
||||||
|
result.RemainingHydrogenPercentStatus = "INVALID"
|
||||||
|
reason("INVALID_PRESSURE_TEMPERATURE")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if !pExists || !tExists {
|
||||||
|
result.RemainingHydrogenPercentStatus = "MISSING"
|
||||||
|
reason("INCOMPLETE_PRESSURE_TEMPERATURE")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if !capacityOK {
|
||||||
|
result.RemainingHydrogenPercentStatus = "MISSING"
|
||||||
|
reason("MISSING_TANK_CAPACITY")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
mass, ok = PressureHydrogenMassKg(pressure, temperature, capacity)
|
||||||
|
if !ok {
|
||||||
|
result.HydrogenDataStatus = "INVALID"
|
||||||
|
result.RemainingHydrogenKgStatus = "INVALID"
|
||||||
|
result.RemainingHydrogenPercentStatus = "INVALID"
|
||||||
|
reason("MASS_CALCULATION_FAILED")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
rounded := round3(mass)
|
||||||
|
source := "ESTIMATED"
|
||||||
|
result.RemainingHydrogenKg = &rounded
|
||||||
|
result.HydrogenValueSource = &source
|
||||||
|
sensorSource := "MAX_SENSOR_AGGREGATE"
|
||||||
|
result.HydrogenPressureTemperatureSource = &sensorSource
|
||||||
|
result.HydrogenEstimatePressureMPa = &pressure
|
||||||
|
result.HydrogenEstimateTemperatureC = &temperature
|
||||||
|
result.RemainingHydrogenKgStatus = "NORMAL"
|
||||||
|
if now.Sub(observedAt) > time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
|
||||||
|
result.RemainingHydrogenKgStatus = "STALE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !capacityOK {
|
||||||
|
result.RemainingHydrogenPercentStatus = "MISSING"
|
||||||
|
reason("MISSING_TANK_CAPACITY")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
percent := mass / full * 100
|
||||||
|
source := "ESTIMATED"
|
||||||
|
result.RemainingHydrogenPercentSource = &source
|
||||||
|
if percent > 100 {
|
||||||
|
result.RemainingHydrogenPercentStatus = "INVALID"
|
||||||
|
result.HydrogenDataStatus = "PARTIAL"
|
||||||
|
if result.RemainingHydrogenKgStatus == "STALE" {
|
||||||
|
result.HydrogenDataStatus = "STALE"
|
||||||
|
}
|
||||||
|
reason("EXCEEDS_NOMINAL_FULL_CAPACITY")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
percent = round3(percent)
|
||||||
|
result.RemainingHydrogenPercent = &percent
|
||||||
|
result.RemainingHydrogenPercentStatus = result.RemainingHydrogenKgStatus
|
||||||
|
result.HydrogenDataStatus = result.RemainingHydrogenKgStatus
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func finiteRealtime(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenEstimation(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
pt := func(p, t float64) string { return fmtRealtimePT(p, t) }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, parsed string
|
||||||
|
cap float64
|
||||||
|
status, source, percentStatus string
|
||||||
|
zero bool
|
||||||
|
}{
|
||||||
|
{"full", pt(35, 15), 520, "NORMAL", "ESTIMATED", "NORMAL", false},
|
||||||
|
{"empty", pt(0, 15), 520, "NORMAL", "ESTIMATED", "NORMAL", true},
|
||||||
|
{"no capacity", pt(20, 15), 0, "MISSING", "", "MISSING", false},
|
||||||
|
{"no temperature", `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, 520, "MISSING", "", "MISSING", false},
|
||||||
|
{"invalid pressure", pt(-1, 15), 520, "INVALID", "", "INVALID", false},
|
||||||
|
{"invalid temperature", pt(20, 6553), 520, "INVALID", "", "INVALID", false},
|
||||||
|
{"over nominal", pt(40, 15), 520, "PARTIAL", "ESTIMATED", "INVALID", false},
|
||||||
|
{"reported wins", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0,"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20,"gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, 520, "NORMAL", "REPORTED", "NORMAL", true},
|
||||||
|
{"invalid reported blocks", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20,"gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, 520, "INVALID", "", "INVALID", false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := realtimeHydrogenWithCapacity(tc.parsed, now, now, tc.cap)
|
||||||
|
if got.HydrogenDataStatus != tc.status || got.RemainingHydrogenPercentStatus != tc.percentStatus {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if tc.source != "" && (got.HydrogenValueSource == nil || *got.HydrogenValueSource != tc.source) {
|
||||||
|
t.Fatalf("source %+v", got)
|
||||||
|
}
|
||||||
|
if tc.zero && (got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 0 || got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 0) {
|
||||||
|
t.Fatalf("zero %+v", got)
|
||||||
|
}
|
||||||
|
if tc.name == "full" && (got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 100) {
|
||||||
|
t.Fatalf("full %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
got := realtimeHydrogenWithCapacity(pt(20, 30), now.Add(-301*time.Second), now, 520)
|
||||||
|
if got.HydrogenDataStatus != "STALE" || got.RemainingHydrogenPercentStatus != "STALE" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
mass, _ := PressureHydrogenMassKg(20, 30, 520)
|
||||||
|
full, _ := PressureHydrogenMassKg(35, 15, 520)
|
||||||
|
if math.Abs(*got.RemainingHydrogenPercent-round3(mass/full*100)) > 1e-6 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func fmtRealtimePT(p, t float64) string {
|
||||||
|
return fmt.Sprintf(`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":%g,"gb32960.fuel_cell.max_hydrogen_temperature_c":%g}`, p, t)
|
||||||
|
}
|
||||||
|
func TestRealtimeCapacityLookupScopedAndOptional(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
mock.ExpectQuery(`SELECT vin,tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND BINARY vin IN`).WithArgs("V1", "V2").WillReturnRows(sqlmock.NewRows([]string{"vin", "tank_capacity_l"}).AddRow("V1", 520).AddRow("V2", 0))
|
||||||
|
got, err := (&MySQLRepository{db: db}).loadRealtimeHydrogenCapacities(context.Background(), []string{"V1", "V2"})
|
||||||
|
if err != nil || got["V1"] != 520 || got["V2"] != 0 {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRealtimePressureFallbackUsesSampleCapacity(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
points := map[string]RealtimeVehiclePoint{"V1": {LiveHydrogen: missingRealtimeHydrogen("GB32960")}}
|
||||||
|
frames := []realtimeRawFrame{{VIN: "V1", Protocol: "GB32960", EventMS: sql.NullInt64{Int64: now.Add(-time.Minute).UnixMilli(), Valid: true}, Parsed: sql.NullString{String: fmtRealtimePT(35, 15), Valid: true}}}
|
||||||
|
applyRealtimeHydrogenFallback(points, []realtimeFrameReference{{VIN: "V1"}}, frames, now, map[string]float64{"V1": 520})
|
||||||
|
if got := points["V1"].LiveHydrogen; got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 100 || *got.HydrogenValueSource != "ESTIMATED" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeCapacityFailurePreservesReportedMeasurement(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tm, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
now := time.Now()
|
||||||
|
vin := "V1"
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "sample", now))
|
||||||
|
tm.ExpectQuery("SELECT vin,protocol,event_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).AddRow(vin, "GB32960", "sample", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":8.125}`))
|
||||||
|
mock.ExpectQuery("SELECT vin,tank_capacity_l").WithArgs(vin).WillReturnError(fmt.Errorf("capacity store unavailable"))
|
||||||
|
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "GB32960"}}
|
||||||
|
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := points[vin].LiveHydrogen
|
||||||
|
if got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 8.125 || got.RemainingHydrogenKgStatus != "NORMAL" || got.RemainingHydrogenPercent != nil || got.RemainingHydrogenPercentStatus != "MISSING" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tm.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenNoFabricatedInputsAndUnroundedDenominator(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
for _, parsed := range []string{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null,"gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"NaN","gb32960.fuel_cell.max_hydrogen_temperature_c":15}`, `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":0,"gb32960.fuel_cell.max_hydrogen_temperature_c":null}`} {
|
||||||
|
got := realtimeHydrogenWithCapacity(parsed, now, now, 520)
|
||||||
|
if got.RemainingHydrogenKg != nil || got.HydrogenDataStatus != "INVALID" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := realtimeHydrogenWithCapacity(`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":5.12345}`, now, now, 520)
|
||||||
|
full, _ := PressureHydrogenMassKg(35, 15, 520)
|
||||||
|
if got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != round3(5.12345/full*100) || *got.HydrogenFullCapacityKg != full {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenFallbackSkipsIncompleteButBlocksInvalid(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
refs := []realtimeFrameReference{{VIN: "V1"}}
|
||||||
|
raw := func(parsed string, age time.Duration) realtimeRawFrame {
|
||||||
|
return realtimeRawFrame{VIN: "V1", Protocol: "GB32960", EventMS: sql.NullInt64{Int64: now.Add(-age).UnixMilli(), Valid: true}, Parsed: sql.NullString{String: parsed, Valid: true}}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ latest, status string }{
|
||||||
|
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, "NORMAL"},
|
||||||
|
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null}`, "INVALID"},
|
||||||
|
} {
|
||||||
|
points := map[string]RealtimeVehiclePoint{"V1": {LiveHydrogen: missingRealtimeHydrogen("GB32960")}}
|
||||||
|
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(fmtRealtimePT(35, 15), 2*time.Minute), raw(tc.latest, time.Minute)}, now, map[string]float64{"V1": 520})
|
||||||
|
got := points["V1"].LiveHydrogen
|
||||||
|
if got.HydrogenDataStatus != tc.status {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if tc.status == "NORMAL" && (got.RemainingHydrogenPercent == nil || *got.RemainingHydrogenPercent != 100) {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := realtimeHydrogenWithCapacity(`{}`, now, now, 520)
|
||||||
|
if got.RemainingHydrogenPercentStatus != "MISSING" || got.HydrogenPercentReason == nil || *got.HydrogenPercentReason != "MISSING_HYDROGEN_MEASUREMENT" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GB32960 live freshness is an API policy, not an assertion about the device's
|
||||||
|
// negotiated reporting interval.
|
||||||
|
const realtimeHydrogenStaleSeconds int64 = 300
|
||||||
|
|
||||||
|
type RealtimeHydrogenData struct {
|
||||||
|
HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"`
|
||||||
|
RemainingHydrogenPercentSource *string `json:"remainingHydrogenPercentSource"`
|
||||||
|
HydrogenFullCapacityKg *float64 `json:"hydrogenFullCapacityKg"`
|
||||||
|
HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"`
|
||||||
|
HydrogenFullPressureMPa *float64 `json:"hydrogenFullPressureMPa"`
|
||||||
|
HydrogenReferenceTemperatureC *float64 `json:"hydrogenReferenceTemperatureC"`
|
||||||
|
HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"`
|
||||||
|
HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"`
|
||||||
|
HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"`
|
||||||
|
HydrogenCapacitySource *string `json:"hydrogenCapacitySource"`
|
||||||
|
HydrogenPercentReason *string `json:"hydrogenPercentReason"`
|
||||||
|
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
||||||
|
RemainingHydrogenPercent *float64 `json:"remainingHydrogenPercent"`
|
||||||
|
HydrogenRecordTime *string `json:"hydrogenRecordTime"`
|
||||||
|
HydrogenDataStatus string `json:"hydrogenDataStatus"`
|
||||||
|
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"`
|
||||||
|
RemainingHydrogenPercentStatus string `json:"remainingHydrogenPercentStatus"`
|
||||||
|
HydrogenValueSource *string `json:"hydrogenValueSource"`
|
||||||
|
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"`
|
||||||
|
HydrogenStaleAfterSeconds *int64 `json:"hydrogenStaleAfterSeconds"`
|
||||||
|
HydrogenExpectedIntervalSeconds *int64 `json:"hydrogenExpectedIntervalSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData {
|
||||||
|
status := "UNSUPPORTED"
|
||||||
|
if protocol == "" || protocol == "GB32960" {
|
||||||
|
status = "MISSING"
|
||||||
|
}
|
||||||
|
result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: status}
|
||||||
|
if protocol == "GB32960" {
|
||||||
|
threshold := realtimeHydrogenStaleSeconds
|
||||||
|
result.HydrogenStaleAfterSeconds = &threshold
|
||||||
|
result.HydrogenSourceProtocol = &protocol
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRealtimeLiveData(item *RealtimeVehicleResult, point RealtimeVehiclePoint, location *time.Location) {
|
||||||
|
item.RealtimeHydrogenData = point.LiveHydrogen
|
||||||
|
if item.HydrogenDataStatus == "" {
|
||||||
|
item.RealtimeHydrogenData = missingRealtimeHydrogen(point.Protocol)
|
||||||
|
}
|
||||||
|
if point.GPSFixStatus != "" {
|
||||||
|
item.GPSFixStatus = point.GPSFixStatus
|
||||||
|
}
|
||||||
|
if point.CoordinateSystem != "" {
|
||||||
|
item.CoordinateSystem = point.CoordinateSystem
|
||||||
|
}
|
||||||
|
if !point.LocationObservedAt.IsZero() {
|
||||||
|
at := point.LocationObservedAt.In(location).Format(time.RFC3339Nano)
|
||||||
|
item.LocationRecordTime = &at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeHydrogenFromFrame(parsed string, observedAt, now time.Time) RealtimeHydrogenData {
|
||||||
|
result := missingRealtimeHydrogen("GB32960")
|
||||||
|
var fields map[string]any
|
||||||
|
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, key := range hydrogenMassFields {
|
||||||
|
value, exists := fields[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A malformed or explicit protocol invalid value must never fall through to
|
||||||
|
// an alias retaining a different reading.
|
||||||
|
mass, valid := numericValue(value)
|
||||||
|
if !observedAt.IsZero() {
|
||||||
|
at := observedAt.In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.RFC3339Nano)
|
||||||
|
result.HydrogenRecordTime = &at
|
||||||
|
}
|
||||||
|
if !valid || math.IsNaN(mass) || math.IsInf(mass, 0) || mass < 0 || mass > 200 || observedAt.IsZero() || observedAt.After(now.Add(time.Minute)) {
|
||||||
|
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "INVALID", "INVALID"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
mass = round3(mass)
|
||||||
|
result.RemainingHydrogenKg = &mass
|
||||||
|
source := "REPORTED"
|
||||||
|
result.HydrogenValueSource = &source
|
||||||
|
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "PARTIAL", "NORMAL"
|
||||||
|
if now.Sub(observedAt) > time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
|
||||||
|
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "STALE", "STALE"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPS flags are read only from the exact raw frame which produced the chosen
|
||||||
|
// location. A snapshot's JSON is merge-patched and cannot prove field freshness.
|
||||||
|
func realtimeGPSFromFrame(protocol, parsed string) (string, string) {
|
||||||
|
fix, coordinate := "UNKNOWN", "UNKNOWN"
|
||||||
|
var fields map[string]any
|
||||||
|
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
||||||
|
return fix, coordinate
|
||||||
|
}
|
||||||
|
switch protocol {
|
||||||
|
case "GB32960":
|
||||||
|
if flag, ok := numericValue(fields["gb32960.position.position_status"]); ok && flag >= 0 && flag <= 7 && math.Trunc(flag) == flag {
|
||||||
|
fix = "FIXED"
|
||||||
|
if int(flag)&1 != 0 {
|
||||||
|
fix = "NO_FIX"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code, exists := fields["gb32960.position.coordinate_system"]; exists {
|
||||||
|
if value, ok := numericValue(code); ok {
|
||||||
|
switch value {
|
||||||
|
case 1:
|
||||||
|
coordinate = "WGS84"
|
||||||
|
case 2:
|
||||||
|
coordinate = "GCJ02"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "JT808":
|
||||||
|
if flag, ok := numericValue(fields["jt808.location.status_flag"]); ok && flag >= 0 && flag <= math.MaxUint32 && math.Trunc(flag) == flag {
|
||||||
|
fix = "NO_FIX"
|
||||||
|
if uint32(flag)&2 != 0 {
|
||||||
|
fix = "FIXED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fix, coordinate
|
||||||
|
}
|
||||||
|
|
||||||
|
type realtimeFrameReference struct {
|
||||||
|
VIN, Protocol, EventID string
|
||||||
|
ReceivedAt time.Time
|
||||||
|
Hydrogen, Location bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeReferenceKey(vin, protocol, eventID string) string {
|
||||||
|
return vin + "\x00" + protocol + "\x00" + eventID
|
||||||
|
}
|
||||||
|
|
||||||
|
// An optional history outage must not make previously available realtime fields
|
||||||
|
// disappear. A shared deadline bounds enrichment even for the 2,000 VIN batch.
|
||||||
|
func (r *MySQLRepository) enrichRealtimeLiveData(ctx context.Context, vins []string, points map[string]RealtimeVehiclePoint, now time.Time) error {
|
||||||
|
for vin, point := range points {
|
||||||
|
point.LiveHydrogen = missingRealtimeHydrogen("")
|
||||||
|
points[vin] = point
|
||||||
|
}
|
||||||
|
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := r.loadRealtimeLiveData(bounded, vins, points, now); err != nil {
|
||||||
|
log.Printf("openplatform realtime enrichment unavailable: type=%T reason=%s", err, realtimeEnrichmentErrorReason(err))
|
||||||
|
// Keep successfully proven raw-frame values, and mark every unresolved
|
||||||
|
// field missing/unknown rather than classifying a storage error unsupported.
|
||||||
|
for vin, point := range points {
|
||||||
|
if point.LiveHydrogen.HydrogenRecordTime == nil {
|
||||||
|
point.LiveHydrogen = missingRealtimeHydrogen("")
|
||||||
|
}
|
||||||
|
points[vin] = point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []string, points map[string]RealtimeVehiclePoint, now time.Time) error {
|
||||||
|
for vin, point := range points {
|
||||||
|
point.LiveHydrogen = missingRealtimeHydrogen(point.Protocol)
|
||||||
|
points[vin] = point
|
||||||
|
}
|
||||||
|
// Repositories used without history retain compatible old fields and explicit
|
||||||
|
// missing statuses; merged MySQL JSON is never used as a fallback.
|
||||||
|
if r.tdengine == nil || r.tdDatabase == "" {
|
||||||
|
return fmt.Errorf("realtime history is not configured")
|
||||||
|
}
|
||||||
|
refs := map[string]realtimeFrameReference{}
|
||||||
|
for vin, point := range points {
|
||||||
|
if point.LocationEventID != "" && !point.LocationReceivedAt.IsZero() {
|
||||||
|
ref := realtimeFrameReference{VIN: vin, Protocol: point.Protocol, EventID: point.LocationEventID, ReceivedAt: point.LocationReceivedAt, Location: true}
|
||||||
|
refs[realtimeReferenceKey(vin, ref.Protocol, ref.EventID)] = ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||||
|
args := make([]any, len(vins))
|
||||||
|
for i, vin := range vins {
|
||||||
|
args[i] = vin
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT vin,protocol,event_id,received_at FROM vehicle_realtime_snapshot WHERE BINARY vin IN (`+placeholders+`) AND protocol='GB32960'`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var ref realtimeFrameReference
|
||||||
|
var received sql.NullTime
|
||||||
|
var eventID sql.NullString
|
||||||
|
if err := rows.Scan(&ref.VIN, &ref.Protocol, &eventID, &received); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
point, exists := points[ref.VIN]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
} // preserve existing realtime row selection contract
|
||||||
|
point.LiveHydrogen = missingRealtimeHydrogen("GB32960")
|
||||||
|
points[ref.VIN] = point
|
||||||
|
if !received.Valid || !eventID.Valid || eventID.String == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref.EventID, ref.ReceivedAt, ref.Hydrogen = eventID.String, received.Time, true
|
||||||
|
key := realtimeReferenceKey(ref.VIN, ref.Protocol, ref.EventID)
|
||||||
|
if prior, ok := refs[key]; ok {
|
||||||
|
ref.Location = prior.Location
|
||||||
|
}
|
||||||
|
refs[key] = ref
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
ordered := make([]realtimeFrameReference, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
ordered = append(ordered, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// At most two references per requested VIN. Batches bound SQL size; primary
|
||||||
|
// timestamp and VIN tag filters prevent unbounded history scans.
|
||||||
|
frames, loadErr := r.loadRealtimeRawFrameBatches(ctx, ordered)
|
||||||
|
capacities, capacityErr := r.loadRealtimeHydrogenCapacities(ctx, vins)
|
||||||
|
if capacityErr != nil {
|
||||||
|
log.Printf("openplatform realtime hydrogen capacity unavailable: %s", realtimeEnrichmentErrorReason(capacityErr))
|
||||||
|
}
|
||||||
|
for _, frame := range frames {
|
||||||
|
ref, exists := refs[realtimeReferenceKey(frame.VIN, frame.Protocol, frame.EventID)]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
point, exists := points[frame.VIN]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ref.Hydrogen {
|
||||||
|
var at time.Time
|
||||||
|
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
||||||
|
at = time.UnixMilli(frame.EventMS.Int64)
|
||||||
|
}
|
||||||
|
point.LiveHydrogen = realtimeHydrogenWithCapacity(frame.Parsed.String, at, now, capacities[frame.VIN])
|
||||||
|
}
|
||||||
|
if ref.Location {
|
||||||
|
point.GPSFixStatus, point.CoordinateSystem = realtimeGPSFromFrame(frame.Protocol, frame.Parsed.String)
|
||||||
|
}
|
||||||
|
points[frame.VIN] = point
|
||||||
|
}
|
||||||
|
if loadErr == nil && ctx.Err() == nil {
|
||||||
|
var missing []realtimeFrameReference
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.Hydrogen && points[ref.VIN].LiveHydrogen.HydrogenDataStatus == "MISSING" {
|
||||||
|
missing = append(missing, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback, err := r.loadRealtimeRawFrameBatchesWithQuery(ctx, missing, realtimeHydrogenFallbackQuery, 1)
|
||||||
|
applyRealtimeHydrogenFallback(points, missing, fallback, now, capacities)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only explicitly missing hydrogen can be filled. In particular, the newest
|
||||||
|
// invalid measurement must never be hidden by an older valid reading.
|
||||||
|
func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs []realtimeFrameReference, frames []realtimeRawFrame, now time.Time, capacityMaps ...map[string]float64) {
|
||||||
|
allowed := make(map[string]bool, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
allowed[ref.VIN] = true
|
||||||
|
}
|
||||||
|
// SQL orders by event_time, not arrival time: delayed retransmissions must not
|
||||||
|
// displace newer measurements. Keep that rule explicit when processing rows.
|
||||||
|
sort.SliceStable(frames, func(i, j int) bool { return frames[i].EventMS.Int64 > frames[j].EventMS.Int64 })
|
||||||
|
for _, frame := range frames {
|
||||||
|
if !allowed[frame.VIN] || frame.Protocol != "GB32960" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
point, exists := points[frame.VIN]
|
||||||
|
if !exists || point.LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var at time.Time
|
||||||
|
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
||||||
|
at = time.UnixMilli(frame.EventMS.Int64)
|
||||||
|
}
|
||||||
|
data := realtimeHydrogenFromFrame(frame.Parsed.String, at, now)
|
||||||
|
if len(capacityMaps) > 0 {
|
||||||
|
data = realtimeHydrogenWithCapacity(frame.Parsed.String, at, now, capacityMaps[0][frame.VIN])
|
||||||
|
}
|
||||||
|
if data.HydrogenDataStatus == "MISSING" {
|
||||||
|
continue
|
||||||
|
} // LIKE is a prefilter, never a JSON parser.
|
||||||
|
point.LiveHydrogen = data
|
||||||
|
points[frame.VIN] = point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type realtimeRawFrame struct {
|
||||||
|
VIN, Protocol, EventID string
|
||||||
|
EventMS sql.NullInt64
|
||||||
|
Parsed sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
type realtimeRawBatchResult struct {
|
||||||
|
frames []realtimeRawFrame
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four workers cap pressure on history storage while avoiding serial latency
|
||||||
|
// across a large authorized fleet. Only the caller writes the result map.
|
||||||
|
func (r *MySQLRepository) loadRealtimeRawFrameBatches(ctx context.Context, refs []realtimeFrameReference) ([]realtimeRawFrame, error) {
|
||||||
|
return r.loadRealtimeRawFrameBatchesWithQuery(ctx, refs, realtimeRawFrameQuery, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) loadRealtimeRawFrameBatchesWithQuery(ctx context.Context, refs []realtimeFrameReference, buildQuery func(string, []realtimeFrameReference) (string, error), maxBatchSize int) ([]realtimeRawFrame, error) {
|
||||||
|
batches := realtimeRawReferenceBatches(refs)
|
||||||
|
if maxBatchSize == 1 {
|
||||||
|
var singleVINBatches [][]realtimeFrameReference
|
||||||
|
for _, batch := range batches {
|
||||||
|
for i := range batch {
|
||||||
|
singleVINBatches = append(singleVINBatches, batch[i:i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batches = singleVINBatches
|
||||||
|
}
|
||||||
|
batchCount := len(batches)
|
||||||
|
if batchCount == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
jobs := make(chan string, batchCount)
|
||||||
|
results := make(chan realtimeRawBatchResult, batchCount)
|
||||||
|
for _, batch := range batches {
|
||||||
|
query, err := buildQuery(r.tdDatabase, batch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs <- query
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
workers := 4
|
||||||
|
if batchCount < workers {
|
||||||
|
workers = batchCount
|
||||||
|
}
|
||||||
|
for worker := 0; worker < workers; worker++ {
|
||||||
|
go func() {
|
||||||
|
for query := range jobs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frames, err := r.loadRealtimeRawFrameBatch(ctx, query)
|
||||||
|
results <- realtimeRawBatchResult{frames: frames, err: err}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
var frames []realtimeRawFrame
|
||||||
|
var firstErr error
|
||||||
|
for batch := 0; batch < batchCount; batch++ {
|
||||||
|
var result realtimeRawBatchResult
|
||||||
|
select {
|
||||||
|
case result = <-results:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return frames, ctx.Err()
|
||||||
|
}
|
||||||
|
if firstErr == nil && result.err != nil {
|
||||||
|
firstErr = result.err
|
||||||
|
}
|
||||||
|
frames = append(frames, result.frames...)
|
||||||
|
}
|
||||||
|
return frames, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) loadRealtimeRawFrameBatch(ctx context.Context, query string) ([]realtimeRawFrame, error) {
|
||||||
|
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var frames []realtimeRawFrame
|
||||||
|
for rows.Next() {
|
||||||
|
var frame realtimeRawFrame
|
||||||
|
if err := rows.Scan(&frame.VIN, &frame.Protocol, &frame.EventID, &frame.EventMS, &frame.Parsed); err != nil {
|
||||||
|
return frames, err
|
||||||
|
}
|
||||||
|
frames = append(frames, frame)
|
||||||
|
}
|
||||||
|
return frames, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep historical outliers away from current data: one query never spans more
|
||||||
|
// than five minutes of arrival time, even when a fleet includes months-old rows.
|
||||||
|
// Newest batches run first so the shared deadline favors currently active cars.
|
||||||
|
func realtimeRawReferenceBatches(refs []realtimeFrameReference) [][]realtimeFrameReference {
|
||||||
|
ordered := append([]realtimeFrameReference(nil), refs...)
|
||||||
|
sort.Slice(ordered, func(i, j int) bool { return ordered[i].ReceivedAt.After(ordered[j].ReceivedAt) })
|
||||||
|
var batches [][]realtimeFrameReference
|
||||||
|
for start := 0; start < len(ordered); {
|
||||||
|
end := start + 1
|
||||||
|
for end < len(ordered) && end-start < 100 && ordered[start].ReceivedAt.Sub(ordered[end].ReceivedAt) <= 5*time.Minute {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
batches = append(batches, ordered[start:end])
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return batches
|
||||||
|
}
|
||||||
|
|
||||||
|
func realtimeRawFrameQuery(database string, refs []realtimeFrameReference) (string, error) {
|
||||||
|
if !validTDIdentifier(database) || len(refs) == 0 || len(refs) > 100 {
|
||||||
|
return "", fmt.Errorf("invalid realtime raw-frame query")
|
||||||
|
}
|
||||||
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||||
|
clauses, vins, timestamps := make([]string, 0, len(refs)), make([]string, 0, len(refs)), make([]string, 0, len(refs))
|
||||||
|
min, max := refs[0].ReceivedAt.UnixMilli(), refs[0].ReceivedAt.UnixMilli()
|
||||||
|
for _, ref := range refs {
|
||||||
|
at := ref.ReceivedAt.UnixMilli()
|
||||||
|
if at < min {
|
||||||
|
min = at
|
||||||
|
}
|
||||||
|
if at > max {
|
||||||
|
max = at
|
||||||
|
}
|
||||||
|
vins = append(vins, quote(ref.VIN))
|
||||||
|
timestamps = append(timestamps, strconv.FormatInt(at, 10))
|
||||||
|
clauses = append(clauses, "(ts="+strconv.FormatInt(at, 10)+" AND vin="+quote(ref.VIN)+" AND protocol="+quote(ref.Protocol)+" AND event_id="+quote(ref.EventID)+")")
|
||||||
|
}
|
||||||
|
return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND ts IN (` + strings.Join(timestamps, ",") + `) AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) LIMIT ` + strconv.Itoa(len(refs)*2), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search only five minutes before one snapshot arrival. TDengine applies LIMIT
|
||||||
|
// globally even with PARTITION BY, so each fallback query must contain one VIN.
|
||||||
|
// The shared worker pool still bounds concurrency to four and uses one deadline.
|
||||||
|
func realtimeHydrogenFallbackQuery(database string, refs []realtimeFrameReference) (string, error) {
|
||||||
|
if !validTDIdentifier(database) || len(refs) != 1 {
|
||||||
|
return "", fmt.Errorf("invalid realtime hydrogen fallback query")
|
||||||
|
}
|
||||||
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||||
|
min, max := refs[0].ReceivedAt.Add(-5*time.Minute).UnixMilli(), refs[0].ReceivedAt.UnixMilli()
|
||||||
|
var clauses, vins, fields []string
|
||||||
|
for _, ref := range refs {
|
||||||
|
start, end := ref.ReceivedAt.Add(-5*time.Minute).UnixMilli(), ref.ReceivedAt.UnixMilli()
|
||||||
|
if start < min {
|
||||||
|
min = start
|
||||||
|
}
|
||||||
|
if end > max {
|
||||||
|
max = end
|
||||||
|
}
|
||||||
|
vins = append(vins, quote(ref.VIN))
|
||||||
|
clauses = append(clauses, "(vin="+quote(ref.VIN)+" AND ts>="+strconv.FormatInt(start, 10)+" AND ts<="+strconv.FormatInt(end, 10)+")")
|
||||||
|
}
|
||||||
|
for _, key := range append(append([]string{}, hydrogenMassFields...), realtimeHydrogenPressureField, realtimeHydrogenTemperatureField) {
|
||||||
|
fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%"))
|
||||||
|
}
|
||||||
|
return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE protocol='GB32960' AND ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 5`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var realtimeErrorQuotedText = regexp.MustCompile(`'[^']*'|"[^"]*"`)
|
||||||
|
var realtimeErrorVIN = regexp.MustCompile(`\b[A-Z0-9]{12,32}\b`)
|
||||||
|
|
||||||
|
// Driver errors sometimes append the whole query. Keep the failure reason for
|
||||||
|
// operations without writing fleet identifiers or SQL literals to the log.
|
||||||
|
func realtimeEnrichmentErrorReason(err error) string {
|
||||||
|
reason := err.Error()
|
||||||
|
upper := strings.ToUpper(reason)
|
||||||
|
for _, keyword := range []string{"SELECT ", "INSERT ", "UPDATE ", "DELETE "} {
|
||||||
|
if index := strings.Index(upper, keyword); index >= 0 {
|
||||||
|
reason = reason[:index] + "[SQL omitted]"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reason = realtimeErrorQuotedText.ReplaceAllString(reason, "[quoted value omitted]")
|
||||||
|
reason = realtimeErrorVIN.ReplaceAllString(reason, "[VIN omitted]")
|
||||||
|
reason = strings.Join(strings.Fields(reason), " ")
|
||||||
|
if len(reason) > 300 {
|
||||||
|
reason = reason[:300] + "..."
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package openplatform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenQualityAndIndependentNulls(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, parsed, status, kgStatus string
|
||||||
|
age time.Duration
|
||||||
|
kg *float64
|
||||||
|
}{
|
||||||
|
{"measured", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":18.6}`, "PARTIAL", "NORMAL", 0, realLiveFloat(18.6)},
|
||||||
|
{"true zero", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "PARTIAL", "NORMAL", 0, realLiveFloat(0)},
|
||||||
|
{"stale", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":18.6}`, "STALE", "STALE", 301 * time.Second, realLiveFloat(18.6)},
|
||||||
|
{"fresh boundary", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":18.6}`, "PARTIAL", "NORMAL", 300 * time.Second, realLiveFloat(18.6)},
|
||||||
|
{"SOC is not hydrogen", `{"gb32960.vehicle.soc_percent":60}`, "MISSING", "MISSING", 0, nil},
|
||||||
|
{"invalid", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":-1}`, "INVALID", "INVALID", 0, nil},
|
||||||
|
{"protocol invalid", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, "INVALID", "INVALID", 0, nil},
|
||||||
|
{"nonfinite", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"NaN"}`, "INVALID", "INVALID", 0, nil},
|
||||||
|
{"future clock", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":1}`, "INVALID", "INVALID", -2 * time.Minute, nil},
|
||||||
|
{"invalid alias must not fallback", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gd_fc_vehicle_hydrogen_mass_kg":10}`, "INVALID", "INVALID", 0, nil},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := realtimeHydrogenFromFrame(tc.parsed, now.Add(-tc.age), now)
|
||||||
|
if got.HydrogenDataStatus != tc.status || got.RemainingHydrogenKgStatus != tc.kgStatus || got.RemainingHydrogenPercent != nil || got.RemainingHydrogenPercentStatus != "MISSING" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if (tc.kg == nil) != (got.RemainingHydrogenKg == nil) || tc.kg != nil && math.Abs(*got.RemainingHydrogenKg-*tc.kg) > 0.0001 {
|
||||||
|
t.Fatalf("kg=%v", got.RemainingHydrogenKg)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(got)
|
||||||
|
if err != nil || !strings.Contains(string(data), `"remainingHydrogenPercent":null`) {
|
||||||
|
t.Fatalf("json=%s err=%v", data, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := missingRealtimeHydrogen("JT808"); got.HydrogenDataStatus != "UNSUPPORTED" || got.RemainingHydrogenKg != nil {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func realLiveFloat(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
func TestRealtimeGPSFlagsIndependentOfFreshness(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ protocol, parsed, fix, coordinate string }{
|
||||||
|
{"GB32960", `{"gb32960.position.position_status":0}`, "FIXED", "UNKNOWN"},
|
||||||
|
{"GB32960", `{"gb32960.position.position_status":1}`, "NO_FIX", "UNKNOWN"},
|
||||||
|
{"GB32960", `{"gb32960.position.position_status":254}`, "UNKNOWN", "UNKNOWN"},
|
||||||
|
{"GB32960", `{"gb32960.position.position_status":0,"gb32960.position.coordinate_system":2}`, "FIXED", "GCJ02"},
|
||||||
|
{"GB32960", `{"gb32960.position.position_status":0,"gb32960.position.coordinate_system":3}`, "FIXED", "UNKNOWN"},
|
||||||
|
{"JT808", `{"jt808.location.status_flag":2}`, "FIXED", "UNKNOWN"},
|
||||||
|
{"JT808", `{"jt808.location.status_flag":0}`, "NO_FIX", "UNKNOWN"},
|
||||||
|
{"YUTONG_MQTT", `{"latitude":23,"longitude":113}`, "UNKNOWN", "UNKNOWN"},
|
||||||
|
} {
|
||||||
|
fix, coordinate := realtimeGPSFromFrame(tc.protocol, tc.parsed)
|
||||||
|
if fix != tc.fix || coordinate != tc.coordinate {
|
||||||
|
t.Fatalf("%+v got %s %s", tc, fix, coordinate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeRawFrameEnrichmentMatchesLocationAndHydrogenSeparately(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tdmock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
now := time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC)
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
points := map[string]RealtimeVehiclePoint{vin: {VIN: vin, Protocol: "JT808", LocationEventID: "loc", LocationReceivedAt: now, LocationObservedAt: now.Add(-time.Hour)}}
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at FROM vehicle_realtime_snapshot").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "hydrogen", now))
|
||||||
|
tdmock.ExpectQuery("SELECT vin,protocol,event_id,CAST\\(event_time AS BIGINT\\),parsed_json FROM vehicle_ts.raw_frames WHERE ts>=.*AND vin IN .*event_id='.*LIMIT 4").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).
|
||||||
|
AddRow(vin, "GB32960", "hydrogen", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`).
|
||||||
|
AddRow(vin, "JT808", "loc", now.Add(-time.Hour).UnixMilli(), `{"jt808.location.status_flag":2}`).
|
||||||
|
AddRow("OTHER", "GB32960", "hydrogen", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":123}`))
|
||||||
|
err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := points[vin]
|
||||||
|
if got.LiveHydrogen.RemainingHydrogenKg == nil || *got.LiveHydrogen.RemainingHydrogenKg != 0 || got.GPSFixStatus != "FIXED" || got.LiveHydrogen.HydrogenDataStatus != "PARTIAL" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeRawFrameLagIsMissingNotError(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tdmock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
now := time.Now()
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "GB32960"}}
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "new", now))
|
||||||
|
tdmock.ExpectQuery("SELECT vin,protocol,event_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}))
|
||||||
|
tdmock.ExpectQuery("SELECT vin,protocol,event_id.*parsed_json LIKE").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}))
|
||||||
|
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if points[vin].LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
||||||
|
t.Fatal(points[vin])
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeRawFrameQueryBoundsAndIdentity(t *testing.T) {
|
||||||
|
at := time.UnixMilli(10000)
|
||||||
|
query, err := realtimeRawFrameQuery("vehicle_ts", []realtimeFrameReference{{VIN: "VIN'1", Protocol: "GB32960", EventID: "id'1", ReceivedAt: at}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"ts>=10000 AND ts<=10000", "ts IN (10000)", "vin='VIN''1'", "protocol='GB32960'", "event_id='id''1'", "LIMIT 2"} {
|
||||||
|
if !strings.Contains(query, want) {
|
||||||
|
t.Fatalf("missing %s in %s", want, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := realtimeRawFrameQuery("bad;sql", []realtimeFrameReference{{}}); err == nil {
|
||||||
|
t.Fatal("accepted database injection")
|
||||||
|
}
|
||||||
|
if _, err := realtimeRawFrameQuery("vehicle_ts", make([]realtimeFrameReference, 101)); err == nil {
|
||||||
|
t.Fatal("accepted unbounded batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeHistoryFailurePreservesOldFields(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tdmock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
now := time.Now()
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "JT808", Longitude: 113, Latitude: 23, Online: true}}
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "new", now))
|
||||||
|
tdmock.ExpectQuery("SELECT vin,protocol,event_id").WillReturnError(context.DeadlineExceeded)
|
||||||
|
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := points[vin]
|
||||||
|
if got.LiveHydrogen.HydrogenDataStatus != "MISSING" || !got.Online || got.Longitude != 113 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeServiceNoFixAndHistoricalFixed(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
for _, fix := range []string{"FIXED", "NO_FIX", "UNKNOWN"} {
|
||||||
|
t.Run(fix, func(t *testing.T) {
|
||||||
|
repository := &fakeRepository{app: AppCredential{ID: 9}, vehicles: map[string]AuthorizedVehicle{"测试车牌": {VIN: vin, Plate: "测试车牌"}}, realtime: map[string]RealtimeVehiclePoint{vin: {VIN: vin, Protocol: "GB32960", Longitude: 113, Latitude: 23, ObservedAt: now.Add(-96 * time.Hour), LocationObservedAt: now.Add(-96 * time.Hour), GPSFixStatus: fix}}}
|
||||||
|
service := NewService(repository)
|
||||||
|
service.now = func() time.Time { return now }
|
||||||
|
result, err := service.QueryRealtimeVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "test", RealtimeVehicleRequest{PlateNumbers: []string{"测试车牌"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := result[0]
|
||||||
|
if got.Online || got.MotionStatus != "offline" || got.GPSFixStatus != fix || got.LocationRecordTime == nil {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if fix == "NO_FIX" {
|
||||||
|
if got.LocationAvailable || got.Longitude != nil || got.Latitude != nil {
|
||||||
|
t.Fatalf("invalid position exposed: %+v", got)
|
||||||
|
}
|
||||||
|
} else if !got.LocationAvailable {
|
||||||
|
t.Fatalf("historical position discarded: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeBatchesReadConcurrentlyWithinSharedDeadline(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
mock.MatchExpectationsInOrder(false)
|
||||||
|
refs := make([]realtimeFrameReference, 400)
|
||||||
|
for i := range refs {
|
||||||
|
refs[i] = realtimeFrameReference{VIN: "VIN", Protocol: "GB32960", EventID: "event", ReceivedAt: time.UnixMilli(int64(i))}
|
||||||
|
}
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id").WillDelayFor(100 * time.Millisecond).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}))
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
repository := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
if _, err := repository.loadRealtimeRawFrameBatches(ctx, refs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeBatchesSeparateHistoricalOutliersAndPreferNewest(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
refs := []realtimeFrameReference{{EventID: "old", ReceivedAt: now.Add(-90 * 24 * time.Hour)}, {EventID: "recent", ReceivedAt: now.Add(-time.Minute)}, {EventID: "newest", ReceivedAt: now}, {EventID: "older", ReceivedAt: now.Add(-6 * time.Minute)}}
|
||||||
|
batches := realtimeRawReferenceBatches(refs)
|
||||||
|
if len(batches) != 3 || len(batches[0]) != 2 || batches[0][0].EventID != "newest" || batches[1][0].EventID != "older" || batches[2][0].EventID != "old" {
|
||||||
|
t.Fatalf("%+v", batches)
|
||||||
|
}
|
||||||
|
if refs[0].EventID != "old" {
|
||||||
|
t.Fatal("mutated input ordering")
|
||||||
|
}
|
||||||
|
refs = make([]realtimeFrameReference, 201)
|
||||||
|
for i := range refs {
|
||||||
|
refs[i].ReceivedAt = now
|
||||||
|
}
|
||||||
|
batches = realtimeRawReferenceBatches(refs)
|
||||||
|
if len(batches) != 3 || len(batches[0]) != 100 || len(batches[1]) != 100 || len(batches[2]) != 1 {
|
||||||
|
t.Fatalf("unexpected count bounds %+v", batches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeMissingHydrogenFallsBackToActualFieldSample(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
td, tdmock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
now := time.Now()
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "GB32960"}}
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "new-location-only", now))
|
||||||
|
columns := []string{"vin", "protocol", "event_id", "event_time", "parsed_json"}
|
||||||
|
tdmock.ExpectQuery("SELECT vin,protocol,event_id.*ts IN").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "new-location-only", now.UnixMilli(), `{"gb32960.vehicle.speed_kmh":20}`))
|
||||||
|
sampleAt := now.Add(-90 * time.Second)
|
||||||
|
tdmock.ExpectQuery("SELECT vin,protocol,event_id.*parsed_json LIKE.*ORDER BY event_time DESC,ts DESC LIMIT 5").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "older-hydrogen", sampleAt.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`))
|
||||||
|
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := points[vin].LiveHydrogen
|
||||||
|
if got.HydrogenDataStatus != "PARTIAL" || got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 0 || got.HydrogenRecordTime == nil || *got.HydrogenValueSource != "REPORTED" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
gotAt, err := time.Parse(time.RFC3339Nano, *got.HydrogenRecordTime)
|
||||||
|
if err != nil || gotAt.UnixMilli() != sampleAt.UnixMilli() {
|
||||||
|
t.Fatalf("time=%s err=%v", *got.HydrogenRecordTime, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenFallbackKeepsNewestInvalidAndSourceIdentity(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
point := RealtimeVehiclePoint{LiveHydrogen: missingRealtimeHydrogen("GB32960")}
|
||||||
|
points := map[string]RealtimeVehiclePoint{vin: point}
|
||||||
|
refs := []realtimeFrameReference{{VIN: vin, Protocol: "GB32960", Hydrogen: true, ReceivedAt: now}}
|
||||||
|
raw := func(vin, protocol, parsed string, at time.Time) realtimeRawFrame {
|
||||||
|
return realtimeRawFrame{VIN: vin, Protocol: protocol, Parsed: sql.NullString{String: parsed, Valid: true}, EventMS: sql.NullInt64{Int64: at.UnixMilli(), Valid: true}}
|
||||||
|
}
|
||||||
|
frames := []realtimeRawFrame{
|
||||||
|
raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`, now.Add(-2*time.Minute)),
|
||||||
|
raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, now.Add(-time.Minute)),
|
||||||
|
raw("OTHER", "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":100}`, now),
|
||||||
|
raw(vin, "JT808", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":100}`, now),
|
||||||
|
}
|
||||||
|
applyRealtimeHydrogenFallback(points, refs, frames, now)
|
||||||
|
if got := points[vin].LiveHydrogen; got.HydrogenDataStatus != "INVALID" || got.RemainingHydrogenKg != nil {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":9}`, now)}, now)
|
||||||
|
if points[vin].LiveHydrogen.HydrogenDataStatus != "INVALID" {
|
||||||
|
t.Fatal("overwrote existing invalid measurement")
|
||||||
|
}
|
||||||
|
points[vin] = point
|
||||||
|
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(vin, "GB32960", `{"note":"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"}`, now)}, now)
|
||||||
|
if points[vin].LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
||||||
|
t.Fatal("accepted text instead of actual field")
|
||||||
|
}
|
||||||
|
points[vin] = point
|
||||||
|
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`, now.Add(-24*time.Hour))}, now)
|
||||||
|
if points[vin].LiveHydrogen.HydrogenDataStatus != "STALE" {
|
||||||
|
t.Fatal("historical sample incorrectly fresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenFallbackQueryLimitsEachVINWindow(t *testing.T) {
|
||||||
|
at := time.UnixMilli(1000000)
|
||||||
|
query, err := realtimeHydrogenFallbackQuery("vehicle_ts", []realtimeFrameReference{{VIN: "VIN'1", ReceivedAt: at}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"protocol='GB32960'", "ts>=700000 AND ts<=1000000", "vin='VIN''1' AND ts>=700000 AND ts<=1000000", `parsed_json LIKE '%"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":%'`, "ORDER BY event_time DESC,ts DESC LIMIT 5"} {
|
||||||
|
if !strings.Contains(query, want) {
|
||||||
|
t.Fatalf("missing %s in %s", want, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "hydrogen_mass_kg IS NOT NULL") {
|
||||||
|
t.Fatal("must include explicit invalid readings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeHydrogenFallbackQueriesEachVINSeparately(t *testing.T) {
|
||||||
|
td, mock, _ := sqlmock.New()
|
||||||
|
defer td.Close()
|
||||||
|
mock.MatchExpectationsInOrder(false)
|
||||||
|
now := time.Now()
|
||||||
|
refs := []realtimeFrameReference{{VIN: "V1", ReceivedAt: now}, {VIN: "V2", ReceivedAt: now}, {VIN: "V3", ReceivedAt: now}}
|
||||||
|
for _, ref := range refs {
|
||||||
|
mock.ExpectQuery("SELECT vin,protocol,event_id.*vin IN \\('" + ref.VIN + "'\\).*ORDER BY event_time DESC,ts DESC LIMIT 5").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).AddRow(ref.VIN, "GB32960", "sample", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`))
|
||||||
|
}
|
||||||
|
repository := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||||
|
frames, err := repository.loadRealtimeRawFrameBatchesWithQuery(context.Background(), refs, realtimeHydrogenFallbackQuery, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unique := map[string]bool{}
|
||||||
|
for _, frame := range frames {
|
||||||
|
unique[frame.VIN] = true
|
||||||
|
}
|
||||||
|
if len(frames) != 3 || len(unique) != 3 {
|
||||||
|
t.Fatalf("frames=%+v", frames)
|
||||||
|
}
|
||||||
|
if _, err := realtimeHydrogenFallbackQuery("vehicle_ts", refs); err == nil {
|
||||||
|
t.Fatal("accepted multi-VIN LIMIT 1 fallback")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeEnrichmentErrorReasonOmitsSQLAndVehicleIdentifiers(t *testing.T) {
|
||||||
|
for _, message := range []string{"driver failed near 'LTEST32960VIN0001': SELECT parsed_json FROM raw_frames WHERE vin='LTEST32960VIN0001'", "invalid LTEST32960VIN0001 \"private value\"", "SELECT vin FROM raw_frames"} {
|
||||||
|
got := realtimeEnrichmentErrorReason(fmt.Errorf("%s", message))
|
||||||
|
if strings.Contains(got, "LTEST32960VIN0001") || strings.Contains(got, "private value") || strings.Contains(got, "SELECT") {
|
||||||
|
t.Fatalf("leaked reason %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := realtimeEnrichmentErrorReason(context.DeadlineExceeded); got != "context deadline exceeded" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
|
ReconciledMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
||||||
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
|
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
|
||||||
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
||||||
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
|
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
|
||||||
@@ -284,13 +285,14 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
|||||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||||
for _, plate := range plates {
|
for _, plate := range plates {
|
||||||
vehicle := vehicles[plate]
|
vehicle := vehicles[plate]
|
||||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData, RealtimeHydrogenData: missingRealtimeHydrogen(""), GPSFixStatus: "UNKNOWN", CoordinateSystem: "UNKNOWN"}
|
||||||
if point, ok := points[vehicle.VIN]; ok {
|
if point, ok := points[vehicle.VIN]; ok {
|
||||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||||
if difference < 0 {
|
if difference < 0 {
|
||||||
difference = 0
|
difference = 0
|
||||||
}
|
}
|
||||||
item.Protocol = externalMileageProtocol(point.Protocol)
|
item.Protocol = externalMileageProtocol(point.Protocol)
|
||||||
|
applyRealtimeLiveData(&item, point, s.location)
|
||||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||||
item.TimeDifferenceSeconds = &difference
|
item.TimeDifferenceSeconds = &difference
|
||||||
item.Online = point.Online
|
item.Online = point.Online
|
||||||
@@ -304,7 +306,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
|||||||
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||||
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||||
item.SOCPercent = point.SOCPercent
|
item.SOCPercent = point.SOCPercent
|
||||||
if validCoordinate(point.Longitude, point.Latitude) {
|
if item.GPSFixStatus != "NO_FIX" && validCoordinate(point.Longitude, point.Latitude) {
|
||||||
longitude, latitude := point.Longitude, point.Latitude
|
longitude, latitude := point.Longitude, point.Latitude
|
||||||
item.Longitude, item.Latitude = &longitude, &latitude
|
item.Longitude, item.Latitude = &longitude, &latitude
|
||||||
item.LocationAvailable = true
|
item.LocationAvailable = true
|
||||||
@@ -346,9 +348,10 @@ func validCoordinate(longitude, latitude float64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repository Repository
|
historicalLimiter historicalHydrogenLimiter
|
||||||
now func() time.Time
|
repository Repository
|
||||||
location *time.Location
|
now func() time.Time
|
||||||
|
location *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type parsedGrant struct {
|
type parsedGrant struct {
|
||||||
@@ -387,11 +390,13 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
|||||||
results := make([]HydrogenResult, 0, len(plates))
|
results := make([]HydrogenResult, 0, len(plates))
|
||||||
for _, plate := range plates {
|
for _, plate := range plates {
|
||||||
vehicle := vehicles[plate]
|
vehicle := vehicles[plate]
|
||||||
item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData}
|
item := HydrogenResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||||
// Sampling sufficiency is decided by the producer and persisted in
|
// Sampling sufficiency is decided by the producer and persisted in
|
||||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||||
// with one transaction, while pressure-derived rows require two samples.
|
// with one transaction, while pressure-derived rows require two samples.
|
||||||
if value, ok := values[vehicle.VIN]; ok {
|
if value, ok := values[vehicle.VIN]; ok {
|
||||||
|
item.StatisticsStartTime, item.StatisticsEndTime = hydrogenStatisticsInterval(value.EvidenceJSON)
|
||||||
|
item.UpdatedAt = validStatisticsTimestamp(value.UpdatedAt)
|
||||||
item.CalculationPhase = value.CalculationPhase
|
item.CalculationPhase = value.CalculationPhase
|
||||||
item.AlgorithmVersion = value.AlgorithmVersion
|
item.AlgorithmVersion = value.AlgorithmVersion
|
||||||
item.QualityStatus = value.QualityStatus
|
item.QualityStatus = value.QualityStatus
|
||||||
@@ -431,35 +436,17 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
|||||||
plates = vehiclePlates(vehicles)
|
plates = vehiclePlates(vehicles)
|
||||||
}
|
}
|
||||||
vins := vehicleVINs(vehicles)
|
vins := vehicleVINs(vehicles)
|
||||||
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
|
values, err := s.repository.ReconciledMileageRange(ctx, vins, date, date, protocols)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
missingVINs := missingMileageVINs(vins, values, rollbacks, date)
|
|
||||||
carried := map[string]DailyMileage{}
|
|
||||||
if len(missingVINs) > 0 {
|
|
||||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results := make([]MileageResult, 0, len(plates))
|
results := make([]MileageResult, 0, len(plates))
|
||||||
for _, plate := range plates {
|
for _, plate := range plates {
|
||||||
vehicle := vehicles[plate]
|
vehicle := vehicles[plate]
|
||||||
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||||
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
if value, ok := values[dailyMileageKey(vehicle.VIN, date)]; ok {
|
||||||
fillMileageResult(&item, value, value.MileageKm)
|
fillMileageResult(&item, value, value.MileageKm)
|
||||||
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
|
|
||||||
fillMileageAnomaly(&item)
|
|
||||||
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
|
||||||
fillMileageResult(&item, value, 0)
|
|
||||||
}
|
}
|
||||||
results = append(results, item)
|
results = append(results, item)
|
||||||
}
|
}
|
||||||
@@ -552,35 +539,17 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
|||||||
vinSet[vehicle.VIN] = struct{}{}
|
vinSet[vehicle.VIN] = struct{}{}
|
||||||
}
|
}
|
||||||
values := map[string]DailyMileage{}
|
values := map[string]DailyMileage{}
|
||||||
carried := map[string]DailyMileage{}
|
|
||||||
rollbacks := map[string]bool{}
|
|
||||||
if len(positions) > 0 {
|
if len(positions) > 0 {
|
||||||
vins := make([]string, 0, len(vinSet))
|
vins := make([]string, 0, len(vinSet))
|
||||||
for vin := range vinSet {
|
for vin := range vinSet {
|
||||||
vins = append(vins, vin)
|
vins = append(vins, vin)
|
||||||
}
|
}
|
||||||
sort.Strings(vins)
|
sort.Strings(vins)
|
||||||
values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
values, err = s.repository.ReconciledMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||||
return MileageRangeResponse{}, err
|
return MileageRangeResponse{}, err
|
||||||
}
|
}
|
||||||
rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
|
||||||
return MileageRangeResponse{}, err
|
|
||||||
}
|
|
||||||
missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks)
|
|
||||||
if len(missingVINs) > 0 {
|
|
||||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
|
||||||
if err != nil {
|
|
||||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
|
||||||
return MileageRangeResponse{}, err
|
|
||||||
}
|
|
||||||
if carried == nil {
|
|
||||||
carried = map[string]DailyMileage{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
results := make([]MileageRangeResult, 0, len(positions))
|
results := make([]MileageRangeResult, 0, len(positions))
|
||||||
for _, position := range positions {
|
for _, position := range positions {
|
||||||
@@ -590,14 +559,8 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
|||||||
Date: position.date,
|
Date: position.date,
|
||||||
Status: StatusNoData,
|
Status: StatusNoData,
|
||||||
}
|
}
|
||||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok {
|
||||||
fillMileageRangeResult(&item, value, value.MileageKm)
|
fillMileageRangeResult(&item, value, value.MileageKm)
|
||||||
carried[position.vehicle.VIN] = value
|
|
||||||
} else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] {
|
|
||||||
fillMileageRangeAnomaly(&item)
|
|
||||||
delete(carried, position.vehicle.VIN)
|
|
||||||
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
|
||||||
fillMileageRangeResult(&item, value, 0)
|
|
||||||
}
|
}
|
||||||
results = append(results, item)
|
results = append(results, item)
|
||||||
}
|
}
|
||||||
@@ -711,6 +674,12 @@ func (s *Service) authorize(ctx context.Context, rawKey string, plates []string,
|
|||||||
if len(plates) > 0 && len(vehicles) != len(plates) {
|
if len(plates) > 0 && len(vehicles) != len(plates) {
|
||||||
return app, nil, ErrForbidden
|
return app, nil, ErrForbidden
|
||||||
}
|
}
|
||||||
|
for _, plate := range plates {
|
||||||
|
vehicle, ok := vehicles[plate]
|
||||||
|
if !ok || strings.TrimSpace(vehicle.VIN) == "" || (vehicle.Plate != "" && vehicle.Plate != plate) {
|
||||||
|
return app, nil, ErrForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
return app, vehicles, nil
|
return app, vehicles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,6 +960,13 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
||||||
|
if value.DataQuality != "" && value.DataTime == "" {
|
||||||
|
fillMileageAnomaly(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if value.Date == item.Date {
|
||||||
|
item.StatisticsStartTime, item.StatisticsEndTime = validatedStatisticsInterval(value.StatisticsStartTime, value.DataTime)
|
||||||
|
}
|
||||||
item.DailyMileageKm = &dailyMileage
|
item.DailyMileageKm = &dailyMileage
|
||||||
totalMileage := value.TotalMileageKm
|
totalMileage := value.TotalMileageKm
|
||||||
item.TotalMileageKm = &totalMileage
|
item.TotalMileageKm = &totalMileage
|
||||||
@@ -1001,9 +977,20 @@ func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage flo
|
|||||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||||
item.SourceProtocol = &sourceProtocol
|
item.SourceProtocol = &sourceProtocol
|
||||||
item.Status = StatusNormal
|
item.Status = StatusNormal
|
||||||
|
if value.DataQuality != "" {
|
||||||
|
item.DataQuality = stringPointer(value.DataQuality)
|
||||||
|
if value.DataQuality != "CARRIED_FORWARD" {
|
||||||
|
item.Status = StatusDataAnomaly
|
||||||
|
item.DailyMileageKm = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
||||||
|
if value.DataQuality != "" && value.DataTime == "" {
|
||||||
|
fillMileageRangeAnomaly(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
item.DailyMileageKm = &dailyMileage
|
item.DailyMileageKm = &dailyMileage
|
||||||
totalMileage := value.TotalMileageKm
|
totalMileage := value.TotalMileageKm
|
||||||
item.TotalMileageKm = &totalMileage
|
item.TotalMileageKm = &totalMileage
|
||||||
@@ -1014,6 +1001,13 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
|
|||||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||||
item.SourceProtocol = &sourceProtocol
|
item.SourceProtocol = &sourceProtocol
|
||||||
item.Status = StatusNormal
|
item.Status = StatusNormal
|
||||||
|
if value.DataQuality != "" {
|
||||||
|
item.DataQuality = stringPointer(value.DataQuality)
|
||||||
|
if value.DataQuality != "CARRIED_FORWARD" {
|
||||||
|
item.Status = StatusDataAnomaly
|
||||||
|
item.DailyMileageKm = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
|
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
|
||||||
|
|||||||
@@ -47,6 +47,56 @@ func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates
|
|||||||
f.requestedPlates = append([]string(nil), plates...)
|
f.requestedPlates = append([]string(nil), plates...)
|
||||||
return f.vehicles, nil
|
return f.vehicles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repository stub retains fixture values; reconciliation itself is tested with
|
||||||
|
// actual dated observations in mileage_reconciliation_test.go.
|
||||||
|
func (f *fakeRepository) ReconciledMileageRange(ctx context.Context, vins []string, start, end string, protocols []string) (map[string]DailyMileage, error) {
|
||||||
|
f.dailyVINs = append([]string(nil), vins...)
|
||||||
|
f.rangeVINs = append([]string(nil), vins...)
|
||||||
|
f.dailyProtocols = protocols
|
||||||
|
out := map[string]DailyMileage{}
|
||||||
|
carried := map[string]DailyMileage{}
|
||||||
|
var missing []string
|
||||||
|
for _, vin := range vins {
|
||||||
|
v, ok := f.mileage[dailyMileageKey(vin, start)]
|
||||||
|
if !ok {
|
||||||
|
v, ok = f.mileage[vin]
|
||||||
|
}
|
||||||
|
if (!ok || !validDailyMileage(v)) && !f.rollbacks[dailyMileageKey(vin, start)] {
|
||||||
|
missing = append(missing, vin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
prior, _ := f.LatestMileageBefore(ctx, missing, start, protocols)
|
||||||
|
for vin, v := range prior {
|
||||||
|
carried[vin] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first, _ := time.Parse("2006-01-02", start)
|
||||||
|
last, _ := time.Parse("2006-01-02", end)
|
||||||
|
for day := first; !day.After(last); day = day.AddDate(0, 0, 1) {
|
||||||
|
date := day.Format("2006-01-02")
|
||||||
|
for _, vin := range vins {
|
||||||
|
key := dailyMileageKey(vin, date)
|
||||||
|
v, ok := f.mileage[key]
|
||||||
|
if !ok {
|
||||||
|
v, ok = f.mileage[vin]
|
||||||
|
}
|
||||||
|
if ok && validDailyMileage(v) {
|
||||||
|
out[key] = v
|
||||||
|
carried[vin] = v
|
||||||
|
} else if f.rollbacks[key] {
|
||||||
|
out[key] = DailyMileage{DataQuality: mileageTotalRollbackQuality}
|
||||||
|
delete(carried, vin)
|
||||||
|
} else if v, ok = carried[vin]; ok && validDailyMileage(v) {
|
||||||
|
v.MileageKm = 0
|
||||||
|
out[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
|
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
|
||||||
f.dailyVINs = append([]string(nil), vins...)
|
f.dailyVINs = append([]string(nil), vins...)
|
||||||
return f.hydrogen, nil
|
return f.hydrogen, nil
|
||||||
@@ -261,7 +311,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
|||||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||||
},
|
},
|
||||||
hydrogen: map[string]DailyHydrogen{
|
hydrogen: map[string]DailyHydrogen{
|
||||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion},
|
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion, EvidenceJSON: `{"lastEventTime":"2026-07-01T11:59:00+08:00"}`, UpdatedAt: "2026-07-01T12:00:00+08:00"},
|
||||||
},
|
},
|
||||||
mileage: map[string]DailyMileage{
|
mileage: map[string]DailyMileage{
|
||||||
"LTEST32960VIN0001": {
|
"LTEST32960VIN0001": {
|
||||||
@@ -282,6 +332,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
|||||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
||||||
t.Fatalf("hydrogen = %#v", hydrogen)
|
t.Fatalf("hydrogen = %#v", hydrogen)
|
||||||
}
|
}
|
||||||
|
if hydrogen[0].VIN != "LTEST32960VIN0001" || hydrogen[1].VIN != "LTEST32960VIN0002" || hydrogen[0].StatisticsStartTime != nil || hydrogen[0].StatisticsEndTime == nil || *hydrogen[0].StatisticsEndTime != "2026-07-01T11:59:00+08:00" || hydrogen[0].UpdatedAt == nil || hydrogen[1].UpdatedAt != nil {
|
||||||
|
t.Fatalf("hydrogen identity/timestamps = %#v", hydrogen)
|
||||||
|
}
|
||||||
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
|
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
|
||||||
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
|
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,6 +413,9 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
|||||||
|
|
||||||
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
|
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
|
||||||
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
|
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
|
||||||
|
if event.RuleID == nativeAlarmRuleID {
|
||||||
|
event.EventCategory, event.EventType = "safety", "vehicle.safety.gb32960_alarm"
|
||||||
|
}
|
||||||
switch strings.ToLower(strings.TrimSpace(event.Status)) {
|
switch strings.ToLower(strings.TrimSpace(event.Status)) {
|
||||||
case "processing":
|
case "processing":
|
||||||
event.ExecutionState = "processing"
|
event.ExecutionState = "processing"
|
||||||
|
|||||||
@@ -169,6 +169,20 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AlertEvent{}, err
|
return AlertEvent{}, err
|
||||||
}
|
}
|
||||||
|
if event.RuleID == nativeAlarmRuleID {
|
||||||
|
var payload []byte
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT fields_json FROM vehicle_native_alarm_evidence WHERE event_id=?`, id).Scan(&payload); err != nil {
|
||||||
|
return AlertEvent{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &event.NativeAlarmFields); err != nil {
|
||||||
|
return AlertEvent{}, err
|
||||||
|
}
|
||||||
|
if description, valid := DescribeNativeAlarm(event.NativeAlarmFields); valid {
|
||||||
|
event.RuleName = description.Title
|
||||||
|
event.NativeAlarmNames = description.Names
|
||||||
|
event.NativeAlarmReservedBits = description.ReservedBits
|
||||||
|
}
|
||||||
|
}
|
||||||
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
|
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AlertEvent{}, err
|
return AlertEvent{}, err
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
if err := recordNativeAlarmsTx(ctx, tx, records, &result); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
if len(rules) == 0 {
|
if len(rules) == 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCorrectedDailyHydrogenRate(t *testing.T) {
|
||||||
|
physical, corrected, mixed, zero := 10.0, 12.0, 200.0, 0.0
|
||||||
|
row := DailyMileageRow{HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, PureHydrogenMileageKm: 100, MixedMileageKm: &mixed}
|
||||||
|
if got := correctedDailyHydrogenRate(row); got == nil || *got != 6 {
|
||||||
|
t.Fatalf("rate must use corrected mass and matching mileage: %v", got)
|
||||||
|
}
|
||||||
|
row.MixedMileageKm = nil
|
||||||
|
if got := correctedDailyHydrogenRate(row); got == nil || *got != 12 {
|
||||||
|
t.Fatalf("fallback mileage: %v", got)
|
||||||
|
}
|
||||||
|
row.HydrogenSOCBalancedKg = nil
|
||||||
|
if got := correctedDailyHydrogenRate(row); got != nil {
|
||||||
|
t.Fatalf("missing correction must not use physical mass: %v", *got)
|
||||||
|
}
|
||||||
|
row.HydrogenSOCBalancedKg = &zero
|
||||||
|
if got := correctedDailyHydrogenRate(row); got == nil || *got != 0 {
|
||||||
|
t.Fatalf("real zero must survive: %v", got)
|
||||||
|
}
|
||||||
|
row.PureHydrogenMileageKm = 0
|
||||||
|
if got := correctedDailyHydrogenRate(row); got != nil {
|
||||||
|
t.Fatalf("zero mileage must have no rate: %v", *got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type distinctHydrogenStore struct{ *MockStore }
|
||||||
|
|
||||||
|
func (s *distinctHydrogenStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||||
|
physical, corrected, distance := 12.813, 8.714, 77.0
|
||||||
|
return Page[DailyMileageRow]{Items: []DailyMileageRow{{VIN: "LA9GG64L1NBAF4167", Date: "2026-09-01", HydrogenConsumptionKg: &physical, HydrogenSOCBalancedKg: &corrected, MixedMileageKm: &distance}}}, nil
|
||||||
|
}
|
||||||
|
func TestDailyMileageHistoricalPublicFieldsUseCorrection(t *testing.T) {
|
||||||
|
result, err := NewService(&distinctHydrogenStore{NewMockStore()}).DailyMileage(context.Background(), url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-01"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row := result.Items[0]
|
||||||
|
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 8.714 || row.HydrogenPhysicalConsumptionKg == nil || *row.HydrogenPhysicalConsumptionKg != 12.813 {
|
||||||
|
t.Fatalf("historical primary amount must be corrected, with physical amount separately available: %+v", row)
|
||||||
|
}
|
||||||
|
if row.HydrogenConsumptionKgPer100Km == nil || *row.HydrogenConsumptionKgPer100Km != 8.714*100/77 {
|
||||||
|
t.Fatalf("historical rate mismatch: %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDailyMileageEnrichesOnlyTheSelectedPage(t *testing.T) {
|
||||||
|
for _, dedup := range []string{"0", "1"} {
|
||||||
|
q := url.Values{"deduplicate": {dedup}, "limit": {"7"}, "offset": {"21"}, "scopeVins": {"VIN1,VIN2"}, "dateFrom": {"2026-08-01"}, "dateTo": {"2026-08-31"}, "protocols": {"JT808,GB32960"}}
|
||||||
|
got := buildDailyMileageSQL(q)
|
||||||
|
pageEnd := strings.Index(got.Text, "LIMIT ? OFFSET ?)")
|
||||||
|
energy := strings.Index(got.Text, "LEFT JOIN vehicle_open_daily_energy")
|
||||||
|
binding := strings.Index(got.Text, "LEFT JOIN vehicle_identity_binding")
|
||||||
|
if pageEnd < 0 || energy < pageEnd || binding < pageEnd {
|
||||||
|
t.Fatalf("enrichment precedes page: %s", got.Text)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.CountText, "JOIN") || strings.Contains(got.CountText, "LIMIT") {
|
||||||
|
t.Fatalf("unneeded joins or pagination in total: %s", got.CountText)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.CountArgs, got.Args[:len(got.Args)-2]) || got.Args[len(got.Args)-2] != 7 || got.Args[len(got.Args)-1] != 21 {
|
||||||
|
t.Fatalf("args data=%#v count=%#v", got.Args, got.CountArgs)
|
||||||
|
}
|
||||||
|
for _, predicate := range []string{"m.vin IN (?,?)", "m.protocol IN (?,?)", "m.stat_date >= ?", "m.stat_date <= ?"} {
|
||||||
|
if !strings.Contains(got.Text, predicate) || !strings.Contains(got.CountText, predicate) {
|
||||||
|
t.Fatalf("lost %s", predicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyMileageCountKeepsBindingOnlyForBindingPredicates(t *testing.T) {
|
||||||
|
for _, q := range []url.Values{{"vin": {"fleet"}}, {"vehicleScope": {"bound"}}, {"vin": {"fleet"}, "deduplicate": {"1"}}, {"vehicleScope": {"bound"}, "deduplicate": {"1"}}} {
|
||||||
|
got := buildDailyMileageSQL(q)
|
||||||
|
if !strings.Contains(got.CountText, "vehicle_identity_binding b") || strings.Contains(got.CountText, "vehicle_open_daily_energy") {
|
||||||
|
t.Fatal(got.CountText)
|
||||||
|
}
|
||||||
|
if strings.Index(got.Text, "vehicle_identity_binding b") > strings.Index(got.Text, "LIMIT ? OFFSET ?)") {
|
||||||
|
t.Fatal("binding filter applied after pagination")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := buildDailyMileageSQL(url.Values{})
|
||||||
|
if len(got.CountArgs) != 0 || strings.Contains(got.CountText, "stat_date >=") || strings.Contains(got.CountText, "stat_date <=") {
|
||||||
|
t.Fatal("unfiltered history total changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ func TestProductionStoreHydrogenDailyEvidenceReturnsParametersIntervalsAndRawEve
|
|||||||
if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
|
if result.Parameters.BatteryCapacityKWh != 21.04 || result.RefuelAmountKg == nil || *result.RefuelAmountKg != 8.976 || result.ChargeEnergyKWh == nil || *result.ChargeEnergyKWh != 12.5 || len(result.Intervals) != 1 || result.Intervals[0].StartEventID != "event-start" || result.Intervals[0].EndEventID != "event-end" {
|
||||||
t.Fatalf("result=%#v", result)
|
t.Fatalf("result=%#v", result)
|
||||||
}
|
}
|
||||||
|
if result.ConsumptionKgPer100Km == nil || *result.ConsumptionKgPer100Km != 5.562 || result.PhysicalConsumptionKgPer100Km == nil || *result.PhysicalConsumptionKgPer100Km != 5.516 {
|
||||||
|
t.Fatalf("evidence must expose corrected rate separately from physical rate: %+v", result)
|
||||||
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Page[T any] struct {
|
type Page[T any] struct {
|
||||||
Items []T `json:"items"`
|
Items []T `json:"items"`
|
||||||
@@ -1000,37 +1003,40 @@ type AlertRuleLifecycleRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlertEvent struct {
|
type AlertEvent struct {
|
||||||
ID string `json:"id"`
|
NativeAlarmNames []string `json:"nativeAlarmNames,omitempty"`
|
||||||
EventType string `json:"eventType"`
|
NativeAlarmReservedBits []int `json:"nativeAlarmReservedBits,omitempty"`
|
||||||
EventCategory string `json:"eventCategory"`
|
NativeAlarmFields map[string]json.RawMessage `json:"nativeAlarmFields,omitempty"`
|
||||||
ExecutionState string `json:"executionState"`
|
ID string `json:"id"`
|
||||||
RuleID string `json:"ruleId"`
|
EventType string `json:"eventType"`
|
||||||
RuleName string `json:"ruleName"`
|
EventCategory string `json:"eventCategory"`
|
||||||
RuleVersion int `json:"ruleVersion"`
|
ExecutionState string `json:"executionState"`
|
||||||
Severity string `json:"severity"`
|
RuleID string `json:"ruleId"`
|
||||||
TriggerType string `json:"triggerType"`
|
RuleName string `json:"ruleName"`
|
||||||
Status string `json:"status"`
|
RuleVersion int `json:"ruleVersion"`
|
||||||
VIN string `json:"vin"`
|
Severity string `json:"severity"`
|
||||||
Plate string `json:"plate"`
|
TriggerType string `json:"triggerType"`
|
||||||
Protocol string `json:"protocol"`
|
Status string `json:"status"`
|
||||||
Metric string `json:"metric"`
|
VIN string `json:"vin"`
|
||||||
Operator string `json:"operator"`
|
Plate string `json:"plate"`
|
||||||
TriggerValue float64 `json:"triggerValue"`
|
Protocol string `json:"protocol"`
|
||||||
Threshold float64 `json:"threshold"`
|
Metric string `json:"metric"`
|
||||||
ThresholdHigh float64 `json:"thresholdHigh"`
|
Operator string `json:"operator"`
|
||||||
Unit string `json:"unit"`
|
TriggerValue float64 `json:"triggerValue"`
|
||||||
DurationSec int `json:"durationSec"`
|
Threshold float64 `json:"threshold"`
|
||||||
Location string `json:"location"`
|
ThresholdHigh float64 `json:"thresholdHigh"`
|
||||||
Longitude *float64 `json:"longitude,omitempty"`
|
Unit string `json:"unit"`
|
||||||
Latitude *float64 `json:"latitude,omitempty"`
|
DurationSec int `json:"durationSec"`
|
||||||
SourceEventID string `json:"sourceEventId"`
|
Location string `json:"location"`
|
||||||
EventAt string `json:"eventAt"`
|
Longitude *float64 `json:"longitude,omitempty"`
|
||||||
ReceivedAt string `json:"receivedAt"`
|
Latitude *float64 `json:"latitude,omitempty"`
|
||||||
TriggeredAt string `json:"triggeredAt"`
|
SourceEventID string `json:"sourceEventId"`
|
||||||
RecoveredAt string `json:"recoveredAt"`
|
EventAt string `json:"eventAt"`
|
||||||
Handler string `json:"handler"`
|
ReceivedAt string `json:"receivedAt"`
|
||||||
Version int `json:"version"`
|
TriggeredAt string `json:"triggeredAt"`
|
||||||
Actions []AlertAction `json:"actions,omitempty"`
|
RecoveredAt string `json:"recoveredAt"`
|
||||||
|
Handler string `json:"handler"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Actions []AlertAction `json:"actions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertAction struct {
|
type AlertAction struct {
|
||||||
@@ -1874,6 +1880,12 @@ type LatestTelemetryResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DailyMileageRow struct {
|
type DailyMileageRow struct {
|
||||||
|
Province string `json:"province,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
LocationTime string `json:"locationTime,omitempty"`
|
||||||
|
LocationStatus string `json:"locationStatus"`
|
||||||
|
HydrogenPhysicalConsumptionKg *float64 `json:"hydrogenPhysicalConsumptionKg,omitempty"`
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
@@ -1942,32 +1954,33 @@ type HydrogenIntervalEvidenceRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HydrogenDailyEvidence struct {
|
type HydrogenDailyEvidence struct {
|
||||||
VIN string `json:"vin"`
|
PhysicalConsumptionKgPer100Km *float64 `json:"physicalConsumptionKgPer100Km,omitempty"`
|
||||||
Plate string `json:"plate"`
|
VIN string `json:"vin"`
|
||||||
Date string `json:"date"`
|
Plate string `json:"plate"`
|
||||||
Source string `json:"source"`
|
Date string `json:"date"`
|
||||||
RawConsumptionKg float64 `json:"rawConsumptionKg"`
|
Source string `json:"source"`
|
||||||
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
RawConsumptionKg float64 `json:"rawConsumptionKg"`
|
||||||
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
BatterySOCDeltaPct *float64 `json:"batterySocDeltaPct,omitempty"`
|
||||||
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
BatteryDischargeKWh *float64 `json:"batteryDischargeKWh,omitempty"`
|
||||||
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
BatteryEquivalentKg *float64 `json:"batteryEquivalentKg,omitempty"`
|
||||||
MixedMileageKm float64 `json:"mixedMileageKm"`
|
SOCBalancedConsumptionKg *float64 `json:"socBalancedConsumptionKg,omitempty"`
|
||||||
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
MixedMileageKm float64 `json:"mixedMileageKm"`
|
||||||
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||||
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
|
ConsumptionKgPer100Km *float64 `json:"consumptionKgPer100Km,omitempty"`
|
||||||
SampleCount int `json:"sampleCount"`
|
SOCBalancedKgPer100Km *float64 `json:"socBalancedKgPer100Km,omitempty"`
|
||||||
RefuelCount int `json:"refuelCount"`
|
SampleCount int `json:"sampleCount"`
|
||||||
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
|
RefuelCount int `json:"refuelCount"`
|
||||||
ChargeCount int `json:"chargeCount"`
|
RefuelAmountKg *float64 `json:"refuelAmountKg,omitempty"`
|
||||||
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
|
ChargeCount int `json:"chargeCount"`
|
||||||
ValidSegmentCount int `json:"validSegmentCount"`
|
ChargeEnergyKWh *float64 `json:"chargeEnergyKWh,omitempty"`
|
||||||
InvalidSegmentCount int `json:"invalidSegmentCount"`
|
ValidSegmentCount int `json:"validSegmentCount"`
|
||||||
QualityStatus string `json:"qualityStatus"`
|
InvalidSegmentCount int `json:"invalidSegmentCount"`
|
||||||
QualityReason string `json:"qualityReason"`
|
QualityStatus string `json:"qualityStatus"`
|
||||||
AlgorithmVersion string `json:"algorithmVersion"`
|
QualityReason string `json:"qualityReason"`
|
||||||
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
|
AlgorithmVersion string `json:"algorithmVersion"`
|
||||||
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
|
Parameters HydrogenCalculationParameterEvidence `json:"parameters"`
|
||||||
CalculatedAt string `json:"calculatedAt"`
|
Intervals []HydrogenIntervalEvidenceRow `json:"intervals"`
|
||||||
|
CalculatedAt string `json:"calculatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MileageQuery is the POST contract used by mileage statistics and daily
|
// MileageQuery is the POST contract used by mileage statistics and daily
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MonitorActivitySummary reads only the two global counters used by the monitor.
|
||||||
|
// A viewport refresh must not run the operations dashboard's health probes.
|
||||||
|
func (s *ProductionStore) MonitorActivitySummary(ctx context.Context) (DashboardSummary, error) {
|
||||||
|
var result DashboardSummary
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT vin) FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> '' AND updated_at >= CURDATE()`).Scan(&result.ActiveToday); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
frames, err := s.frameToday(ctx, time.Now())
|
||||||
|
result.FrameToday = frames
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type monitorActivityTestStore struct {
|
||||||
|
*MockStore
|
||||||
|
activityCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *monitorActivityTestStore) MonitorActivitySummary(context.Context) (DashboardSummary, error) {
|
||||||
|
s.activityCalls++
|
||||||
|
return DashboardSummary{ActiveToday: 17, FrameToday: 123456}, nil
|
||||||
|
}
|
||||||
|
func (s *monitorActivityTestStore) DashboardSummary(context.Context) (DashboardSummary, error) {
|
||||||
|
panic("monitor must not run full dashboard and health probes")
|
||||||
|
}
|
||||||
|
func TestMonitorWorkspaceUsesOnlyRequiredActivityCounters(t *testing.T) {
|
||||||
|
store := &monitorActivityTestStore{MockStore: NewMockStore()}
|
||||||
|
got, err := NewService(store).MonitorWorkspace(context.Background(), url.Values{"limit": {"10000"}, "zoom": {"5"}, "railLimit": {"200"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if store.activityCalls != 1 || got.Summary.ActiveToday != 17 || got.Summary.FrameToday != 123456 {
|
||||||
|
t.Fatalf("calls=%d summary=%+v", store.activityCalls, got.Summary)
|
||||||
|
}
|
||||||
|
if got.Summary.TotalVehicles == 0 || len(got.Vehicles.Items) == 0 {
|
||||||
|
t.Fatal("lost fleet data")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -554,13 +554,20 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
|||||||
}
|
}
|
||||||
countArgs := append([]any(nil), args...)
|
countArgs := append([]any(nil), args...)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
fromSQL := `FROM vehicle_daily_mileage m
|
// Both joins are one-to-one. Binding is needed during filtering only for
|
||||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
// keyword/bound scope; energy never affects which mileage rows qualify.
|
||||||
|
filterFromSQL := `FROM vehicle_daily_mileage m`
|
||||||
|
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") || strings.TrimSpace(query.Get("vin")) != "" {
|
||||||
|
filterFromSQL += ` LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin`
|
||||||
|
}
|
||||||
|
filterFromSQL += ` WHERE ` + strings.Join(where, " AND ")
|
||||||
|
enrichmentSQL := ` LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||||
LEFT JOIN vehicle_open_daily_energy h
|
LEFT JOIN vehicle_open_daily_energy h
|
||||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||||
AND h.stat_date = m.stat_date
|
AND h.stat_date = m.stat_date
|
||||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
|
AND h.energy_type = 'HYDROGEN' AND h.quality_status IN ('OK','SUSPECT')
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
LEFT JOIN vehicle_daily_geography g ON g.vin = m.vin COLLATE utf8mb4_unicode_ci AND g.stat_date = m.stat_date`
|
||||||
|
|
||||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||||
@@ -570,30 +577,26 @@ WHERE ` + strings.Join(where, " AND ")
|
|||||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||||
}
|
}
|
||||||
groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date`
|
groupSQL := filterFromSQL + ` GROUP BY m.vin, m.stat_date`
|
||||||
|
// LIMIT materializes the grouped page before enrichment. Keep the exact
|
||||||
|
// original aggregate/priority rules, including independent maxima when no
|
||||||
|
// enabled protocol priority was supplied.
|
||||||
|
pageSQL := `SELECT m.vin, m.stat_date, ` +
|
||||||
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
||||||
|
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
||||||
|
dailyMileageExpression + ` AS daily_mileage_km, ` + pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||||
|
`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 ?`
|
||||||
built := SQLQuery{
|
built := SQLQuery{
|
||||||
Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
Text: `SELECT m.vin, COALESCE(NULLIF(b.plate, ''), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
`m.start_mileage_km, m.end_mileage_km, m.daily_mileage_km, m.pure_hydrogen_mileage_km, ` +
|
||||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
`h.consumption_kg AS hydrogen_consumption_kg, h.consumption_kg_per_100km AS hydrogen_consumption_kg_per_100km, ` +
|
||||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
`h.soc_balanced_consumption_kg AS hydrogen_soc_balanced_kg, h.soc_balanced_kg_per_100km AS hydrogen_soc_balanced_kg_per_100km, ` +
|
||||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
`h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, ` +
|
||||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
`CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END AS hydrogen_evidence_available, ` +
|
||||||
`MAX(h.consumption_kg_per_100km) AS hydrogen_consumption_kg_per_100km, ` +
|
`COALESCE(h.quality_status, '') AS hydrogen_quality_status, COALESCE(h.quality_reason, '') AS hydrogen_quality_reason, ` +
|
||||||
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_soc_balanced_kg, ` +
|
`COALESCE(h.algorithm_version, '') AS hydrogen_algorithm_version, m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
|
||||||
`MAX(h.soc_balanced_kg_per_100km) AS hydrogen_soc_balanced_kg_per_100km, ` +
|
`FROM (` + pageSQL + `) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC`,
|
||||||
`MAX(h.pure_electric_mileage_km) AS pure_electric_mileage_km, ` +
|
|
||||||
`MAX(h.mixed_mileage_km) AS mixed_mileage_km, ` +
|
|
||||||
`MAX(h.battery_soc_delta_pct) AS battery_soc_delta_pct, ` +
|
|
||||||
`MAX(h.charge_count) AS charge_count, ` +
|
|
||||||
`MAX(h.charge_energy_kwh) AS charge_energy_kwh, ` +
|
|
||||||
`MAX(h.refuel_count) AS refuel_count, ` +
|
|
||||||
`MAX(h.refuel_amount_kg) AS refuel_amount_kg, ` +
|
|
||||||
`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.quality_reason), '') AS hydrogen_quality_reason, ` +
|
|
||||||
`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,
|
Args: args,
|
||||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||||
CountArgs: countArgs,
|
CountArgs: countArgs,
|
||||||
@@ -611,10 +614,10 @@ WHERE ` + strings.Join(where, " AND ")
|
|||||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, h.consumption_kg_per_100km, h.soc_balanced_consumption_kg, ` +
|
`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, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
|
`h.soc_balanced_kg_per_100km, h.pure_electric_mileage_km, h.mixed_mileage_km, h.battery_soc_delta_pct, ` +
|
||||||
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
`h.charge_count, h.charge_energy_kwh, h.refuel_count, h.refuel_amount_kg, CASE WHEN h.evidence_json IS NOT NULL THEN 1 ELSE 0 END, ` +
|
||||||
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol ` +
|
`COALESCE(h.quality_status, ''), COALESCE(h.quality_reason, ''), COALESCE(h.algorithm_version, ''), m.protocol, ` + dailyGeographyProjectionSQL + ` ` +
|
||||||
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
`FROM (SELECT m.* ` + filterFromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?) m` + enrichmentSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC`,
|
||||||
Args: args,
|
Args: args,
|
||||||
CountText: `SELECT COUNT(*) ` + fromSQL,
|
CountText: `SELECT COUNT(*) ` + filterFromSQL,
|
||||||
CountArgs: countArgs,
|
CountArgs: countArgs,
|
||||||
}
|
}
|
||||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||||
@@ -708,7 +711,7 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
|||||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_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, ` +
|
`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, ` +
|
`MAX(h.soc_balanced_consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||||
`FROM vehicle_daily_mileage m
|
`FROM vehicle_daily_mileage m
|
||||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||||
@@ -915,3 +918,5 @@ func mustInt(value string) int {
|
|||||||
n, _ := strconv.Atoi(value)
|
n, _ := strconv.Atoi(value)
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dailyGeographyProjectionSQL = `COALESCE(g.province,''),COALESCE(g.city,''),COALESCE(g.region,''),COALESCE(DATE_FORMAT(g.location_time,'%Y-%m-%dT%H:%i:%s.%f+08:00'),''),CASE COALESCE(g.status,'PENDING') WHEN 'RESOLVED' THEN '已解析' WHEN 'NO_LOCATION' THEN '当天无有效定位' WHEN 'ERROR' THEN '解析失败,后台重试中' ELSE '后台待解析' END`
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const nativeAlarmRuleID = "native-gb32960-alarm"
|
||||||
|
|
||||||
|
// Require the complete 0x07 unit: absent/invalid data must never clear an alarm.
|
||||||
|
func nativeAlarmFields(record AlertStreamRecord) (map[string]json.RawMessage, int, bool, bool) {
|
||||||
|
if record.Protocol != "GB32960" {
|
||||||
|
return nil, 0, false, false
|
||||||
|
}
|
||||||
|
fields := make(map[string]json.RawMessage, 6)
|
||||||
|
level, levelOK := alertStreamRawNumber(record.Fields["gb32960.alarm.max_alarm_level"])
|
||||||
|
flag, flagOK := alertStreamRawNumber(record.Fields["gb32960.alarm.general_alarm_flag"])
|
||||||
|
if !levelOK || !flagOK || level < 0 || level > 3 || math.Trunc(level) != level || flag < 0 || flag > math.MaxUint32 || math.Trunc(flag) != flag {
|
||||||
|
return nil, 0, false, false
|
||||||
|
}
|
||||||
|
mask := nativeAlarm2016Mask
|
||||||
|
if nativeAlarmVersion(record.Fields) == "V2025" {
|
||||||
|
mask = math.MaxUint32
|
||||||
|
}
|
||||||
|
active := level > 0 || uint32(flag)&mask != 0
|
||||||
|
if version := record.Fields["gb32960.header.version"]; len(version) > 0 {
|
||||||
|
fields["gb32960.header.version"] = version
|
||||||
|
}
|
||||||
|
for _, name := range []string{"max_alarm_level", "general_alarm_flag", "battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||||
|
key := "gb32960.alarm." + name
|
||||||
|
raw := record.Fields[key]
|
||||||
|
if name != "max_alarm_level" && name != "general_alarm_flag" {
|
||||||
|
// The gateway's FIELDS contract serializes complex KV values as JSON strings.
|
||||||
|
var encoded string
|
||||||
|
if json.Unmarshal(raw, &encoded) == nil {
|
||||||
|
raw = json.RawMessage(encoded)
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
if len(raw) == 0 || string(raw) == "null" || json.Unmarshal(raw, &codes) != nil || codes == nil {
|
||||||
|
return nil, 0, false, false
|
||||||
|
}
|
||||||
|
active = active || len(codes) > 0
|
||||||
|
}
|
||||||
|
fields[key] = raw
|
||||||
|
}
|
||||||
|
return fields, int(level), active, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs in the same transaction as the Kafka checkpoint, independently of rules.
|
||||||
|
func recordNativeAlarmsTx(ctx context.Context, tx *sql.Tx, records []AlertStreamRecord, result *AlertEvaluationResult) error {
|
||||||
|
eligible := make([]AlertStreamRecord, 0)
|
||||||
|
for _, record := range records {
|
||||||
|
if _, _, _, ok := nativeAlarmFields(record); ok && !record.Late {
|
||||||
|
eligible = append(eligible, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(eligible) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
metadata, err := loadAlertStreamVehicleMetadata(ctx, tx, eligible)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, record := range eligible {
|
||||||
|
fields, level, active, _ := nativeAlarmFields(record)
|
||||||
|
// Serialize concurrent consumers for this VIN, including the first observation.
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO vehicle_native_alarm_state(vin,last_event_at) VALUES(?,'1970-01-01 00:00:00')`, record.VIN); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var lastAt time.Time
|
||||||
|
var eventID string
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT last_event_at,active_event_id FROM vehicle_native_alarm_state WHERE vin=? FOR UPDATE`, record.VIN).Scan(&lastAt, &eventID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !record.EventAt.After(lastAt) {
|
||||||
|
result.LateObservations++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if active && eventID == "" {
|
||||||
|
eventID, err = newAlertID("alert")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
severity := "minor"
|
||||||
|
if level == 2 {
|
||||||
|
severity = "major"
|
||||||
|
}
|
||||||
|
if level == 3 {
|
||||||
|
severity = "critical"
|
||||||
|
}
|
||||||
|
description, _ := DescribeNativeAlarm(fields)
|
||||||
|
title := description.Title
|
||||||
|
rule := AlertRule{ID: nativeAlarmRuleID, Name: title, Severity: severity, TriggerType: "metric", Metric: "alarm_active", Operator: "eq", Threshold: 1}
|
||||||
|
item := alertStreamEvidence(record, metadata[record.VIN], nil)
|
||||||
|
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||||
|
item.EventAt = record.EventAt.In(shanghai).Format("2006-01-02 15:04:05.999")
|
||||||
|
item.ReceivedAt = record.ReceivedAt.In(shanghai).Format("2006-01-02 15:04:05.999")
|
||||||
|
query, args := buildAlertEventInsert(eventID, nativeAlarmRuleID+"|"+record.VIN+"|GB32960", rule, item, 1)
|
||||||
|
// Missing coordinates must not appear as a real position at (0,0).
|
||||||
|
if !item.HasLocation {
|
||||||
|
args[17], args[18], args[19] = "", nil, nil
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload, marshalErr := json.Marshal(fields)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return marshalErr
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_native_alarm_evidence(event_id,fields_json) VALUES(?,?)`, eventID, string(payload)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','native-gb32960','车辆上报原生告警')`, eventID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Opened++
|
||||||
|
} else if !active && eventID != "" {
|
||||||
|
update, updateErr := tx.ExecContext(ctx, `UPDATE vehicle_alert_event SET status='recovered',recovered_at=?,version=version+1 WHERE id=? AND status IN ('unprocessed','processing')`, record.EventAt, eventID)
|
||||||
|
if updateErr != nil {
|
||||||
|
return updateErr
|
||||||
|
}
|
||||||
|
count, countErr := update.RowsAffected()
|
||||||
|
if countErr != nil {
|
||||||
|
return countErr
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'recover','','recovered','native-gb32960','车辆上报原生告警解除')`, eventID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Recovered++
|
||||||
|
}
|
||||||
|
eventID = ""
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_native_alarm_state SET last_event_at=?,active_event_id=? WHERE vin=?`, record.EventAt, eventID, record.VIN); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GB/T 32960.3-2016, table 18. Bits 19–31 are reserved, not faults.
|
||||||
|
var nativeAlarmBitNames = [...]string{
|
||||||
|
"温度差异报警", "电池高温报警", "车载储能装置类型过压报警", "车载储能装置类型欠压报警",
|
||||||
|
"SOC低报警", "单体电池过压报警", "单体电池欠压报警", "SOC过高报警", "SOC跳变报警",
|
||||||
|
"可充电储能系统不匹配报警", "电池单体一致性差报警", "绝缘报警", "DC-DC温度报警", "制动系统报警",
|
||||||
|
"DC-DC状态报警", "驱动电机控制器温度报警", "高压互锁状态报警", "驱动电机温度报警", "车载储能装置类型过充报警",
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeAlarm2016Mask uint32 = (1 << 19) - 1
|
||||||
|
|
||||||
|
// NativeAlarmDescription is also used by the repair command so historical and
|
||||||
|
// newly ingested events use the same interpretation of their saved evidence.
|
||||||
|
type NativeAlarmDescription struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
ReservedBits []int `json:"reservedBits"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeAlarmVersion(fields map[string]json.RawMessage) string {
|
||||||
|
var version string
|
||||||
|
_ = json.Unmarshal(fields["gb32960.header.version"], &version)
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
func DescribeNativeAlarm(fields map[string]json.RawMessage) (NativeAlarmDescription, bool) {
|
||||||
|
fields, level, active, valid := nativeAlarmFields(AlertStreamRecord{Protocol: "GB32960", Fields: fields})
|
||||||
|
result := NativeAlarmDescription{Names: []string{}, ReservedBits: []int{}, Active: active}
|
||||||
|
if !valid {
|
||||||
|
return result, false
|
||||||
|
}
|
||||||
|
value, _ := alertStreamRawNumber(fields["gb32960.alarm.general_alarm_flag"])
|
||||||
|
flag := uint32(value)
|
||||||
|
for bit, name := range nativeAlarmBitNames {
|
||||||
|
if flag&(uint32(1)<<bit) != 0 {
|
||||||
|
result.Names = append(result.Names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for bit := 19; bit < 32; bit++ {
|
||||||
|
if flag&(uint32(1)<<bit) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nativeAlarmVersion(fields) == "V2025" {
|
||||||
|
result.Names = append(result.Names, fmt.Sprintf("扩展报警位 bit%d(待匹配2025版定义)", bit))
|
||||||
|
} else {
|
||||||
|
result.ReservedBits = append(result.ReservedBits, bit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range []struct{ key, label string }{
|
||||||
|
{"battery_faults", "可充电储能装置"}, {"motor_faults", "驱动电机"}, {"engine_faults", "发动机"}, {"other_faults", "其他"},
|
||||||
|
} {
|
||||||
|
var codes []string
|
||||||
|
_ = json.Unmarshal(fields["gb32960.alarm."+group.key], &codes)
|
||||||
|
for _, code := range codes {
|
||||||
|
result.Names = append(result.Names, group.label+"故障码 "+code+"(厂商定义)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.Names) == 0 && level > 0 {
|
||||||
|
result.Names = append(result.Names, fmt.Sprintf("%d级故障(车辆未上报具体故障项)", level))
|
||||||
|
}
|
||||||
|
if len(result.Names) == 0 {
|
||||||
|
result.Title = "无标准告警"
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
result.Title = strings.Join(result.Names, "、")
|
||||||
|
// Persist a useful, bounded title in the existing VARCHAR(80) column; detail
|
||||||
|
// keeps every name and code, including concurrent faults.
|
||||||
|
if len([]rune(result.Title)) > 80 {
|
||||||
|
first := []rune(result.Names[0])
|
||||||
|
if len(first) > 55 {
|
||||||
|
first = first[:55]
|
||||||
|
}
|
||||||
|
result.Title = fmt.Sprintf("%s 等%d项告警", string(first), len(result.Names))
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nativeAlarmRecord(level, flag string) AlertStreamRecord {
|
||||||
|
fields := map[string]json.RawMessage{"gb32960.alarm.max_alarm_level": json.RawMessage(level), "gb32960.alarm.general_alarm_flag": json.RawMessage(flag)}
|
||||||
|
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||||
|
fields["gb32960.alarm."+name] = json.RawMessage(`[]`)
|
||||||
|
}
|
||||||
|
return AlertStreamRecord{Protocol: "GB32960", VIN: "VIN1", SourceEventID: "native-source", EventAt: time.Date(2026, 9, 17, 1, 0, 0, 0, time.UTC), ReceivedAt: time.Date(2026, 9, 17, 1, 0, 1, 0, time.UTC), Fields: fields, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeAlarmFields(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, level, flag string
|
||||||
|
active, valid bool
|
||||||
|
}{
|
||||||
|
{"clear", "0", `"0x00000000"`, false, true},
|
||||||
|
{"level", "3", `0`, true, true},
|
||||||
|
{"flag", "0", `"0x00000004"`, true, true},
|
||||||
|
{"reserved only", "0", `"0x00300000"`, false, true},
|
||||||
|
{"reserved and insulation", "0", `"0x00380800"`, true, true},
|
||||||
|
{"abnormal", "254", `0`, false, false},
|
||||||
|
{"invalid", "255", `0`, false, false},
|
||||||
|
{"fraction", "1.5", `0`, false, false},
|
||||||
|
{"bad bitmap", "0", `"garbage"`, false, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, _, active, valid := nativeAlarmFields(nativeAlarmRecord(tc.level, tc.flag))
|
||||||
|
if active != tc.active || valid != tc.valid {
|
||||||
|
t.Fatalf("active=%v valid=%v", active, valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
encodedRecord := nativeAlarmRecord(`"2"`, `"0x00000004"`)
|
||||||
|
for _, name := range []string{"battery_faults", "motor_faults", "engine_faults", "other_faults"} {
|
||||||
|
encodedRecord.Fields["gb32960.alarm."+name] = json.RawMessage(`"[]"`)
|
||||||
|
}
|
||||||
|
encodedRecord.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x00000001\"]"`)
|
||||||
|
fields, _, active, valid := nativeAlarmFields(encodedRecord)
|
||||||
|
if !valid || !active || string(fields["gb32960.alarm.motor_faults"]) != `["0x00000001"]` {
|
||||||
|
t.Fatalf("gateway string-encoded fault arrays lost: %v", fields)
|
||||||
|
}
|
||||||
|
record := nativeAlarmRecord("0", "0")
|
||||||
|
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`["0x00000001"]`)
|
||||||
|
if _, _, active, valid := nativeAlarmFields(record); !active || !valid {
|
||||||
|
t.Fatal("fault code alone must activate alarm")
|
||||||
|
}
|
||||||
|
delete(record.Fields, "gb32960.alarm.battery_faults")
|
||||||
|
if _, _, _, valid := nativeAlarmFields(record); valid {
|
||||||
|
t.Fatal("partial unit must not activate or recover")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeAlarmLifecycleWithoutRules(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, existing string
|
||||||
|
clear, stale, closed bool
|
||||||
|
}{
|
||||||
|
{name: "open without rules"},
|
||||||
|
{name: "continuous alarm deduplicated", existing: "existing"},
|
||||||
|
{name: "recover", existing: "existing", clear: true},
|
||||||
|
{name: "manual closure preserved", existing: "existing", clear: true, closed: true},
|
||||||
|
{name: "out of order ignored", existing: "existing", clear: true, stale: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
record := nativeAlarmRecord("2", `"0x00000004"`)
|
||||||
|
if tc.clear {
|
||||||
|
record = nativeAlarmRecord("0", "0")
|
||||||
|
}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||||
|
mock.ExpectQuery(`SELECT v.vin,`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem", "model", "company"}).AddRow("VIN1", "粤A12345", "", "", ""))
|
||||||
|
mock.ExpectExec(`INSERT IGNORE INTO vehicle_native_alarm_state`).WithArgs("VIN1").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
last := record.EventAt.Add(-time.Second)
|
||||||
|
if tc.stale {
|
||||||
|
last = record.EventAt.Add(time.Second)
|
||||||
|
}
|
||||||
|
mock.ExpectQuery(`SELECT last_event_at,active_event_id`).WithArgs("VIN1").WillReturnRows(sqlmock.NewRows([]string{"last_event_at", "active_event_id"}).AddRow(last, tc.existing))
|
||||||
|
if !tc.stale {
|
||||||
|
if tc.existing == "" {
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(alertEventInsertSQL)).WithArgs(sqlmock.AnyArg(), nativeAlarmRuleID+"|VIN1|GB32960", nativeAlarmRuleID, "车载储能装置类型过压报警", 0, "major", "metric", "VIN1", "粤A12345", "GB32960", "alarm_active", "eq", float64(1), float64(1), float64(0), "", 0, "", nil, nil, "native-source", sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
payload, _ := json.Marshal(record.Fields)
|
||||||
|
mock.ExpectExec(`INSERT INTO vehicle_native_alarm_evidence`).WithArgs(sqlmock.AnyArg(), string(payload)).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
} else if tc.clear {
|
||||||
|
count := int64(1)
|
||||||
|
if tc.closed {
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
mock.ExpectExec(`UPDATE vehicle_alert_event SET status='recovered'`).WithArgs(record.EventAt, tc.existing).WillReturnResult(sqlmock.NewResult(0, count))
|
||||||
|
if !tc.closed {
|
||||||
|
mock.ExpectExec(`INSERT INTO vehicle_alert_event_action`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock.ExpectExec(`UPDATE vehicle_native_alarm_state`).WithArgs(record.EventAt, sqlmock.AnyArg(), "VIN1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
}
|
||||||
|
mock.ExpectCommit()
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := evaluateAlertStreamRecordsTx(t.Context(), tx, []AlertStreamRecord{record}, record.ReceivedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.RulesEvaluated != 0 {
|
||||||
|
t.Fatal("native alarms require no automation")
|
||||||
|
}
|
||||||
|
if tc.existing == "" && result.Opened != 1 {
|
||||||
|
t.Fatalf("not opened: %+v", result)
|
||||||
|
}
|
||||||
|
if tc.existing != "" && result.Opened != 0 {
|
||||||
|
t.Fatal("duplicate event")
|
||||||
|
}
|
||||||
|
if tc.clear && !tc.closed && !tc.stale && result.Recovered != 1 {
|
||||||
|
t.Fatal("not recovered")
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeAlarmLateAndMissingUnitsDoNotWrite(t *testing.T) {
|
||||||
|
db, mock, _ := sqlmock.New()
|
||||||
|
defer db.Close()
|
||||||
|
late := nativeAlarmRecord("3", "1")
|
||||||
|
late.Late = true
|
||||||
|
missing := nativeAlarmRecord("0", "0")
|
||||||
|
delete(missing.Fields, "gb32960.alarm.general_alarm_flag")
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectCommit()
|
||||||
|
tx, _ := db.Begin()
|
||||||
|
if err := recordNativeAlarmsTx(t.Context(), tx, []AlertStreamRecord{late, missing}, &AlertEvaluationResult{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeAlarmDescriptionDecodesSpecificFaultsAndReservedBits(t *testing.T) {
|
||||||
|
record := nativeAlarmRecord("0", `"0x00380800"`)
|
||||||
|
d, ok := DescribeNativeAlarm(record.Fields)
|
||||||
|
if !ok || !d.Active || d.Title != "绝缘报警" || len(d.Names) != 1 || len(d.ReservedBits) != 3 || d.ReservedBits[0] != 19 {
|
||||||
|
t.Fatalf("wrong description: %+v", d)
|
||||||
|
}
|
||||||
|
record = nativeAlarmRecord("0", `"0x00380000"`)
|
||||||
|
d, ok = DescribeNativeAlarm(record.Fields)
|
||||||
|
if !ok || d.Active || len(d.Names) != 0 {
|
||||||
|
t.Fatalf("reserved bits became faults: %+v", d)
|
||||||
|
}
|
||||||
|
record = nativeAlarmRecord("2", `"0x00020810"`)
|
||||||
|
record.Fields["gb32960.alarm.motor_faults"] = json.RawMessage(`"[\"0x12345678\"]"`)
|
||||||
|
d, ok = DescribeNativeAlarm(record.Fields)
|
||||||
|
if !ok || len(d.Names) != 4 || d.Names[0] != "SOC低报警" || d.Names[1] != "绝缘报警" || d.Names[2] != "驱动电机温度报警" || d.Names[3] != "驱动电机故障码 0x12345678(厂商定义)" {
|
||||||
|
t.Fatalf("lost concurrent faults: %+v", d)
|
||||||
|
}
|
||||||
|
record = nativeAlarmRecord("3", `"0x00000000"`)
|
||||||
|
d, ok = DescribeNativeAlarm(record.Fields)
|
||||||
|
if !ok || !d.Active || d.Title != "3级故障(车辆未上报具体故障项)" {
|
||||||
|
t.Fatalf("invented fault meaning: %+v", d)
|
||||||
|
}
|
||||||
|
for bit, name := range nativeAlarmBitNames {
|
||||||
|
record = nativeAlarmRecord("0", fmt.Sprintf("%d", uint32(1)<<bit))
|
||||||
|
d, ok = DescribeNativeAlarm(record.Fields)
|
||||||
|
if !ok || !d.Active || d.Title != name {
|
||||||
|
t.Fatalf("bit %d decoded as %+v", bit, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record = nativeAlarmRecord("0", `"0x00300000"`)
|
||||||
|
record.Fields["gb32960.header.version"] = json.RawMessage(`"V2025"`)
|
||||||
|
d, ok = DescribeNativeAlarm(record.Fields)
|
||||||
|
if !ok || !d.Active || len(d.Names) != 2 || len(d.ReservedBits) != 0 {
|
||||||
|
t.Fatalf("2025 extension must not be silently discarded: %+v", d)
|
||||||
|
}
|
||||||
|
record = nativeAlarmRecord("2", `"0x0007FFFF"`)
|
||||||
|
d, _ = DescribeNativeAlarm(record.Fields)
|
||||||
|
if len(d.Names) != 19 || len([]rune(d.Title)) > 80 {
|
||||||
|
t.Fatalf("title overflow or lost detail: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1158,7 +1158,7 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
|||||||
var evidenceAvailable int
|
var evidenceAvailable int
|
||||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm,
|
||||||
&hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
|
&hydrogen, &physicalRate, &balanced, &balancedRate, &pureElectric, &mixed, &socDelta, &chargeCount, &chargeEnergy, &refuelCount, &refuelAmount,
|
||||||
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source); err != nil {
|
&evidenceAvailable, &row.HydrogenQualityStatus, &row.HydrogenQualityReason, &row.HydrogenAlgorithmVersion, &row.Source, &row.Province, &row.City, &row.Region, &row.LocationTime, &row.LocationStatus); err != nil {
|
||||||
return Page[DailyMileageRow]{}, err
|
return Page[DailyMileageRow]{}, err
|
||||||
}
|
}
|
||||||
if hydrogen.Valid {
|
if hydrogen.Valid {
|
||||||
@@ -1237,7 +1237,8 @@ LIMIT 1`, vin, date)
|
|||||||
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
result.BatteryDischargeKWh = nullableFloatPointer(discharge)
|
||||||
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
result.BatteryEquivalentKg = nullableFloatPointer(equivalent)
|
||||||
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
result.SOCBalancedConsumptionKg = nullableFloatPointer(balanced)
|
||||||
result.ConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
result.PhysicalConsumptionKgPer100Km = nullableFloatPointer(physicalRate)
|
||||||
|
result.ConsumptionKgPer100Km = nullableFloatPointer(balancedRate)
|
||||||
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
result.SOCBalancedKgPer100Km = nullableFloatPointer(balancedRate)
|
||||||
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
|
result.RefuelAmountKg = nullableFloatPointer(refuelAmount)
|
||||||
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
|
result.ChargeEnergyKWh = nullableFloatPointer(chargeEnergy)
|
||||||
@@ -1267,7 +1268,7 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
|||||||
result := MileageStatistics{
|
result := MileageStatistics{
|
||||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
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)
|
summary := buildMileageStatisticsSummarySQL(query)
|
||||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
|
|||||||
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
|
"hydrogen_consumption_kg", "hydrogen_consumption_kg_per_100km", "hydrogen_soc_balanced_kg", "hydrogen_soc_balanced_kg_per_100km",
|
||||||
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
|
"pure_electric_mileage_km", "mixed_mileage_km", "battery_soc_delta_pct", "charge_count", "charge_energy_kwh",
|
||||||
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
|
"refuel_count", "refuel_amount_kg", "hydrogen_evidence_available", "hydrogen_quality_status", "hydrogen_quality_reason",
|
||||||
"hydrogen_algorithm_version", "protocol",
|
"hydrogen_algorithm_version", "protocol", "province", "city", "region", "location_time", "location_status",
|
||||||
}
|
}
|
||||||
mock.ExpectQuery("SELECT m.vin").
|
mock.ExpectQuery("SELECT m.vin").
|
||||||
WithArgs(20, 0).
|
WithArgs(20, 0).
|
||||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||||
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
|
"LB9A32A29R0LS1423", "粤AGR9816", "2026-08-26", 23119.8, 23333.1, 213.3, 213.3,
|
||||||
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
|
4.491, 2.106, 4.544, 2.130, 0.0, 213.3, -4.0, 1, 12.5,
|
||||||
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960",
|
1, 8.976, 1, "SUSPECT", "疑似管路泄压", "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5", "GB32960", "广东省", "广州市", "华南", "2026-08-26T23:59:00+08:00", "已解析",
|
||||||
))
|
))
|
||||||
|
|
||||||
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
|
page, err := (&ProductionStore{db: db}).DailyMileage(context.Background(), url.Values{"skipCount": {"1"}})
|
||||||
@@ -42,6 +42,9 @@ func TestProductionStoreDailyMileageReturnsSuspectHydrogenExportDetails(t *testi
|
|||||||
t.Fatalf("items=%#v", page.Items)
|
t.Fatalf("items=%#v", page.Items)
|
||||||
}
|
}
|
||||||
row := page.Items[0]
|
row := page.Items[0]
|
||||||
|
if row.Province != "广东省" || row.City != "广州市" || row.Region != "华南" || row.LocationStatus != "已解析" {
|
||||||
|
t.Fatalf("missing persisted geography: %+v", row)
|
||||||
|
}
|
||||||
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
|
if row.HydrogenConsumptionKg == nil || *row.HydrogenConsumptionKg != 4.491 || row.HydrogenQualityStatus != "SUSPECT" || row.HydrogenQualityReason == "" ||
|
||||||
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
|
row.ChargeCount == nil || *row.ChargeCount != 1 || row.ChargeEnergyKWh == nil || *row.ChargeEnergyKWh != 12.5 ||
|
||||||
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
|
row.RefuelCount == nil || *row.RefuelCount != 1 || row.RefuelAmountKg == nil || *row.RefuelAmountKg != 8.976 {
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *te
|
|||||||
|
|
||||||
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
||||||
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
||||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))", "MAX(h.consumption_kg)", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))", "h.consumption_kg AS hydrogen_consumption_kg", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||||
if !strings.Contains(built.Text+built.CountText, want) {
|
if !strings.Contains(built.Text+built.CountText, want) {
|
||||||
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||||
}
|
}
|
||||||
@@ -693,7 +693,7 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
|||||||
"GROUP BY m.vin, m.stat_date",
|
"GROUP BY m.vin, m.stat_date",
|
||||||
"MAX(COALESCE(m.daily_mileage_km",
|
"MAX(COALESCE(m.daily_mileage_km",
|
||||||
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
|
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
|
||||||
"MAX(h.consumption_kg)",
|
"MAX(h.soc_balanced_consumption_kg)",
|
||||||
"COUNT(DISTINCT d.vin)",
|
"COUNT(DISTINCT d.vin)",
|
||||||
"SUM(d.daily_mileage_km)",
|
"SUM(d.daily_mileage_km)",
|
||||||
"SUM(d.pure_hydrogen_mileage_km)",
|
"SUM(d.pure_hydrogen_mileage_km)",
|
||||||
|
|||||||
@@ -544,8 +544,18 @@ func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (Monitor
|
|||||||
return s.buildMonitorSummary(ctx, query, vehicles)
|
return s.buildMonitorSummary(ctx, query, vehicles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type monitorActivityStore interface {
|
||||||
|
MonitorActivitySummary(context.Context) (DashboardSummary, error)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, vehicles Page[VehicleRealtimeRow]) (MonitorSummary, error) {
|
func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, vehicles Page[VehicleRealtimeRow]) (MonitorSummary, error) {
|
||||||
dashboard, err := s.store.DashboardSummary(ctx)
|
var dashboard DashboardSummary
|
||||||
|
var err error
|
||||||
|
if store, ok := s.store.(monitorActivityStore); ok {
|
||||||
|
dashboard, err = store.MonitorActivitySummary(ctx)
|
||||||
|
} else {
|
||||||
|
dashboard, err = s.store.DashboardSummary(ctx)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MonitorSummary{}, err
|
return MonitorSummary{}, err
|
||||||
}
|
}
|
||||||
@@ -5581,10 +5591,18 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Page[DailyMileageRow]{}, err
|
return Page[DailyMileageRow]{}, err
|
||||||
}
|
}
|
||||||
|
for index := range result.Items {
|
||||||
|
row := &result.Items[index]
|
||||||
|
row.HydrogenPhysicalConsumptionKg = row.HydrogenConsumptionKg
|
||||||
|
row.HydrogenConsumptionKg = row.HydrogenSOCBalancedKg
|
||||||
|
row.HydrogenConsumptionKgPer100Km = correctedDailyHydrogenRate(*row)
|
||||||
|
}
|
||||||
|
|
||||||
if !hydrogenConsumptionAllowed(ctx) {
|
if !hydrogenConsumptionAllowed(ctx) {
|
||||||
for index := range result.Items {
|
for index := range result.Items {
|
||||||
result.Items[index].PureHydrogenMileageKm = 0
|
result.Items[index].PureHydrogenMileageKm = 0
|
||||||
result.Items[index].HydrogenConsumptionKg = nil
|
result.Items[index].HydrogenConsumptionKg = nil
|
||||||
|
result.Items[index].HydrogenPhysicalConsumptionKg = nil
|
||||||
result.Items[index].HydrogenConsumptionKgPer100Km = nil
|
result.Items[index].HydrogenConsumptionKgPer100Km = nil
|
||||||
result.Items[index].HydrogenSOCBalancedKg = nil
|
result.Items[index].HydrogenSOCBalancedKg = nil
|
||||||
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
|
result.Items[index].HydrogenSOCBalancedKgPer100Km = nil
|
||||||
@@ -6692,3 +6710,16 @@ func boolToInt(value bool) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// correctedDailyHydrogenRate keeps physical consumption available for audit,
|
||||||
|
// but never uses it as a fallback for the displayed consumption rate.
|
||||||
|
func correctedDailyHydrogenRate(row DailyMileageRow) *float64 {
|
||||||
|
if row.HydrogenSOCBalancedKg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mileage := row.PureHydrogenMileageKm
|
||||||
|
if row.MixedMileageKm != nil && *row.MixedMileageKm > 0 {
|
||||||
|
mileage = *row.MixedMileageKm
|
||||||
|
}
|
||||||
|
return hydrogenRatePer100Km(*row.HydrogenSOCBalancedKg, mileage, 1)
|
||||||
|
}
|
||||||
|
|||||||
@@ -629,7 +629,7 @@ func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T)
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
|
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil ||
|
||||||
daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
|
daily.Items[0].HydrogenPhysicalConsumptionKg != nil || daily.Items[0].ChargeEnergyKWh != nil || daily.Items[0].RefuelAmountKg != nil || daily.Items[0].HydrogenQualityStatus != "" || daily.Items[0].HydrogenEvidenceAvailable {
|
||||||
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
|
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
|
||||||
}
|
}
|
||||||
summary, err := service.MileageStatistics(customer, query)
|
summary, err := service.MileageStatistics(customer, query)
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "http://115.29.187.205:20200"
|
||||||
|
hydrogenKWh = 16.0
|
||||||
|
)
|
||||||
|
|
||||||
|
type candidate struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
StatDate string `json:"stat_date"`
|
||||||
|
PlatformName string `json:"platform_name"`
|
||||||
|
DailyMileageKm float64 `json:"daily_mileage_km"`
|
||||||
|
RawTotal int `json:"raw_total"`
|
||||||
|
Plate string `json:"plate,omitempty"`
|
||||||
|
SampleNo int `json:"sample_no,omitempty"`
|
||||||
|
MileageBin int `json:"mileage_bin,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
TankCapacityL float64 `json:"tank_capacity_l,omitempty"`
|
||||||
|
BatteryKWh float64 `json:"battery_capacity_kwh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type capacityRecord struct {
|
||||||
|
VIN, Plate, Model string
|
||||||
|
TankCapacityL float64
|
||||||
|
Active int
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawFrame struct {
|
||||||
|
TS string `json:"ts"`
|
||||||
|
FrameID string `json:"frame_id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
MessageIDHex string `json:"message_id_hex"`
|
||||||
|
EventTime string `json:"event_time"`
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
RawSizeBytes int `json:"raw_size_bytes"`
|
||||||
|
RawHex string `json:"raw_hex"`
|
||||||
|
ParsedFields map[string]any `json:"parsed_fields"`
|
||||||
|
ParseStatus string `json:"parse_status"`
|
||||||
|
SourceEndpoint string `json:"source_endpoint"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawResponse struct {
|
||||||
|
Items []rawFrame `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dayOutput struct {
|
||||||
|
Candidate candidate `json:"candidate"`
|
||||||
|
APIRawFrameCount int `json:"apiRawFrameCount"`
|
||||||
|
UniqueFrameCount int `json:"uniqueFrameCount"`
|
||||||
|
DuplicateFrameCount int `json:"duplicateFrameCount"`
|
||||||
|
AlgorithmSamples int `json:"algorithmSamples"`
|
||||||
|
CriticalFieldRows int `json:"criticalFieldRows"`
|
||||||
|
EarliestEventTime string `json:"earliestEventTime"`
|
||||||
|
LatestEventTime string `json:"latestEventTime"`
|
||||||
|
RawArchiveFile string `json:"rawArchiveFile"`
|
||||||
|
RawArchiveSHA256 string `json:"rawArchiveSha256"`
|
||||||
|
RawArchiveBytes int64 `json:"rawArchiveBytes"`
|
||||||
|
Stat openplatform.HydrogenDailyStat `json:"stat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) != 5 && len(os.Args) != 6 {
|
||||||
|
panic("usage: audit <vehicle-metadata.json> <start-date> <end-date> <output-dir> [source-archive-dir]")
|
||||||
|
}
|
||||||
|
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
|
||||||
|
sourceArchiveDir := ""
|
||||||
|
if len(os.Args) == 6 {
|
||||||
|
sourceArchiveDir = os.Args[5]
|
||||||
|
}
|
||||||
|
start, err := time.Parse("2006-01-02", startDate)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
end, err := time.Parse("2006-01-02", endDate)
|
||||||
|
if err != nil || end.Before(start) {
|
||||||
|
panic("invalid date range")
|
||||||
|
}
|
||||||
|
var metadata []struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||||
|
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||||
|
}
|
||||||
|
mustReadJSON(metadataPath, &metadata)
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
panic("vehicle metadata is empty")
|
||||||
|
}
|
||||||
|
selected := make([]candidate, 0, len(metadata)*int(end.Sub(start).Hours()/24+1))
|
||||||
|
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||||
|
statDate := day.Format("2006-01-02")
|
||||||
|
for _, item := range metadata {
|
||||||
|
if len(strings.TrimSpace(item.VIN)) != 17 || item.TankCapacityL <= 0 || item.BatteryCapacityKWh <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selected = append(selected, candidate{
|
||||||
|
VIN: strings.ToUpper(strings.TrimSpace(item.VIN)), StatDate: statDate,
|
||||||
|
Plate: item.Plate, Model: item.Model, TankCapacityL: item.TankCapacityL,
|
||||||
|
BatteryKWh: item.BatteryCapacityKWh,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(selected, func(i, j int) bool {
|
||||||
|
if selected[i].StatDate != selected[j].StatDate {
|
||||||
|
return selected[i].StatDate < selected[j].StatDate
|
||||||
|
}
|
||||||
|
if selected[i].Plate != selected[j].Plate {
|
||||||
|
return selected[i].Plate < selected[j].Plate
|
||||||
|
}
|
||||||
|
return selected[i].VIN < selected[j].VIN
|
||||||
|
})
|
||||||
|
for index := range selected {
|
||||||
|
selected[index].SampleNo = index + 1
|
||||||
|
}
|
||||||
|
if sourceArchiveDir == "" {
|
||||||
|
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||||
|
if err := os.MkdirAll(filepath.Join(outputDir, "raw", day.Format("2006-01-02")), 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(filepath.Join(outputDir, "selected_vehicles.json"), selected)
|
||||||
|
|
||||||
|
jobs := make(chan candidate)
|
||||||
|
results := make(chan dayOutput)
|
||||||
|
errs := make(chan error, len(selected))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for c := range jobs {
|
||||||
|
out, err := processDay(c, outputDir, sourceArchiveDir)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results <- out
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for _, c := range selected {
|
||||||
|
jobs <- c
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
close(errs)
|
||||||
|
}()
|
||||||
|
outputs := make([]dayOutput, 0, len(selected))
|
||||||
|
for out := range results {
|
||||||
|
if out.APIRawFrameCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outputs = append(outputs, out)
|
||||||
|
fmt.Fprintf(os.Stderr, "completed %03d %s %s raw=%d samples=%d quality=%s\n", out.Candidate.SampleNo, out.Candidate.Plate, out.Candidate.VIN, out.APIRawFrameCount, out.AlgorithmSamples, out.Stat.QualityStatus)
|
||||||
|
}
|
||||||
|
var allErrs []string
|
||||||
|
for err := range errs {
|
||||||
|
allErrs = append(allErrs, err.Error())
|
||||||
|
}
|
||||||
|
if len(allErrs) > 0 {
|
||||||
|
panic(strings.Join(allErrs, "\n"))
|
||||||
|
}
|
||||||
|
sort.Slice(outputs, func(i, j int) bool { return outputs[i].Candidate.SampleNo < outputs[j].Candidate.SampleNo })
|
||||||
|
writeJSON(filepath.Join(outputDir, "daily_results.json"), outputs)
|
||||||
|
writeManifest(filepath.Join(outputDir, "raw_manifest.csv"), outputs)
|
||||||
|
fmt.Printf("processed=%d raw_frames=%d algorithm_samples=%d intervals=%d\n", len(outputs), sumRaw(outputs), sumSamples(outputs), sumIntervals(outputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stratifiedSelect(all []candidate, plateByVIN map[string]string, capacityByVIN map[string]capacityRecord) []candidate {
|
||||||
|
general, cold := make([]candidate, 0), make([]candidate, 0)
|
||||||
|
for _, c := range all {
|
||||||
|
cap, ok := capacityByVIN[strings.ToUpper(c.VIN)]
|
||||||
|
if !ok || cap.Active != 1 || c.DailyMileageKm < 10 || c.DailyMileageKm > 600 || c.RawTotal < 300 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cap.Model != "4.5吨货车" && cap.Model != "帕力安牌4.5吨冷链车" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Plate = cap.Plate
|
||||||
|
if c.Plate == "" {
|
||||||
|
c.Plate = plateByVIN[strings.ToUpper(c.VIN)]
|
||||||
|
}
|
||||||
|
c.Model = cap.Model
|
||||||
|
c.TankCapacityL = cap.TankCapacityL
|
||||||
|
if cap.Model == "4.5吨货车" {
|
||||||
|
general = append(general, c)
|
||||||
|
} else {
|
||||||
|
cold = append(cold, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(general) < 41 || len(cold) < 59 {
|
||||||
|
panic(fmt.Sprintf("insufficient pools general=%d cold=%d", len(general), len(cold)))
|
||||||
|
}
|
||||||
|
selected := append(selectEvenly(general, 41), selectEvenly(cold, 59)...)
|
||||||
|
sort.Slice(selected, func(i, j int) bool {
|
||||||
|
if selected[i].DailyMileageKm != selected[j].DailyMileageKm {
|
||||||
|
return selected[i].DailyMileageKm < selected[j].DailyMileageKm
|
||||||
|
}
|
||||||
|
return selected[i].VIN < selected[j].VIN
|
||||||
|
})
|
||||||
|
for i := range selected {
|
||||||
|
selected[i].SampleNo = i + 1
|
||||||
|
selected[i].MileageBin = i/10 + 1
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectEvenly(values []candidate, count int) []candidate {
|
||||||
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
if values[i].DailyMileageKm != values[j].DailyMileageKm {
|
||||||
|
return values[i].DailyMileageKm < values[j].DailyMileageKm
|
||||||
|
}
|
||||||
|
return values[i].VIN < values[j].VIN
|
||||||
|
})
|
||||||
|
out := make([]candidate, 0, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
idx := 0
|
||||||
|
if count > 1 {
|
||||||
|
idx = int(math.Round(float64(i) * float64(len(values)-1) / float64(count-1)))
|
||||||
|
}
|
||||||
|
out = append(out, values[idx])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDay(c candidate, outputDir, sourceArchiveDir string) (dayOutput, error) {
|
||||||
|
archiveName := rawArchiveName(c)
|
||||||
|
archivePath := filepath.Join(outputDir, "raw", c.StatDate, archiveName)
|
||||||
|
readArchivePath := archivePath
|
||||||
|
if sourceArchiveDir != "" {
|
||||||
|
readArchivePath = filepath.Join(sourceArchiveDir, "raw", c.StatDate, archiveName)
|
||||||
|
if _, statErr := os.Stat(readArchivePath); os.IsNotExist(statErr) {
|
||||||
|
// Sample numbers depend on the selected vehicle/date set. Reuse an
|
||||||
|
// existing archive by its stable plate/VIN/date suffix when rerunning
|
||||||
|
// only a small subset for validation.
|
||||||
|
pattern := filepath.Join(sourceArchiveDir, "raw", c.StatDate, "*_"+c.Plate+"_"+c.VIN+"_"+c.StatDate+".csv.gz")
|
||||||
|
if matches, _ := filepath.Glob(pattern); len(matches) == 1 {
|
||||||
|
readArchivePath = matches[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames := []rawFrame(nil)
|
||||||
|
total := 0
|
||||||
|
var err error
|
||||||
|
if _, statErr := os.Stat(readArchivePath); statErr == nil {
|
||||||
|
frames, err = readRawArchive(readArchivePath)
|
||||||
|
total = len(frames)
|
||||||
|
} else if sourceArchiveDir != "" && os.IsNotExist(statErr) {
|
||||||
|
return dayOutput{Candidate: c}, nil
|
||||||
|
} else {
|
||||||
|
for attempt := 1; attempt <= 3; attempt++ {
|
||||||
|
frames, total, err = fetchFrames(c.VIN, c.StatDate)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if attempt < 3 {
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return dayOutput{}, fmt.Errorf("%s %s: %w", c.StatDate, c.VIN, err)
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return dayOutput{Candidate: c}, nil
|
||||||
|
}
|
||||||
|
sort.SliceStable(frames, func(i, j int) bool {
|
||||||
|
if frames[i].EventTime != frames[j].EventTime {
|
||||||
|
return frames[i].EventTime < frames[j].EventTime
|
||||||
|
}
|
||||||
|
if frames[i].SourceEndpoint != frames[j].SourceEndpoint {
|
||||||
|
return frames[i].SourceEndpoint < frames[j].SourceEndpoint
|
||||||
|
}
|
||||||
|
return frames[i].TS < frames[j].TS
|
||||||
|
})
|
||||||
|
seen := map[string]bool{}
|
||||||
|
duplicateFrames := 0
|
||||||
|
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
|
||||||
|
criticalRows := 0
|
||||||
|
for _, f := range frames {
|
||||||
|
if f.FrameID != "" {
|
||||||
|
if seen[f.FrameID] {
|
||||||
|
duplicateFrames++
|
||||||
|
}
|
||||||
|
seen[f.FrameID] = true
|
||||||
|
}
|
||||||
|
obs, ok, critical := observationFromFrame(f, c.TankCapacityL, c.StatDate)
|
||||||
|
if critical {
|
||||||
|
criticalRows++
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
observations = append(observations, obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params := map[string]openplatform.HydrogenCalculationParameters{c.VIN: {BatteryCapacityKWh: c.BatteryKWh, HydrogenEnergyKWhKg: hydrogenKWh}}
|
||||||
|
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, c.StatDate, 0.05, 20, params)
|
||||||
|
var stat openplatform.HydrogenDailyStat
|
||||||
|
if len(stats) == 1 {
|
||||||
|
stat = stats[0]
|
||||||
|
} else {
|
||||||
|
stat = openplatform.HydrogenDailyStat{VIN: c.VIN, Date: c.StatDate, SampleCount: len(observations), QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
|
||||||
|
}
|
||||||
|
roles := map[string]string{}
|
||||||
|
for _, interval := range stat.Intervals {
|
||||||
|
appendRole(roles, interval.StartEventID, fmt.Sprintf("运行区间%d(%s)起点", interval.Index, interval.Type))
|
||||||
|
appendRole(roles, interval.EndEventID, fmt.Sprintf("运行区间%d(%s)终点", interval.Index, interval.Type))
|
||||||
|
}
|
||||||
|
for _, interval := range stat.HydrogenIntervals {
|
||||||
|
appendRole(roles, interval.StartEventID, fmt.Sprintf("氢量分段%d起点", interval.Index))
|
||||||
|
appendRole(roles, interval.EndEventID, fmt.Sprintf("氢量分段%d终点", interval.Index))
|
||||||
|
}
|
||||||
|
// Even when the original frames are reused, rewrite the audit CSV so its
|
||||||
|
// “计算角色/排除原因” column matches the current algorithm version and
|
||||||
|
// interval boundaries. The original HEX and parsed fields remain unchanged.
|
||||||
|
if sourceArchiveDir == "" {
|
||||||
|
if err := writeRawCSV(archivePath, c, frames, roles); err != nil {
|
||||||
|
return dayOutput{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checksum, size, err := fileSHA256(readArchivePath)
|
||||||
|
if err != nil {
|
||||||
|
return dayOutput{}, err
|
||||||
|
}
|
||||||
|
out := dayOutput{Candidate: c, APIRawFrameCount: total, UniqueFrameCount: len(seen), DuplicateFrameCount: duplicateFrames, AlgorithmSamples: len(observations), CriticalFieldRows: criticalRows, RawArchiveFile: filepath.ToSlash(filepath.Join("raw", c.StatDate, archiveName)), RawArchiveSHA256: checksum, RawArchiveBytes: size, Stat: stat}
|
||||||
|
if len(frames) > 0 {
|
||||||
|
out.EarliestEventTime = frames[0].EventTime
|
||||||
|
out.LatestEventTime = frames[len(frames)-1].EventTime
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawArchiveName(c candidate) string {
|
||||||
|
return fmt.Sprintf("%04d_%s_%s_%s.csv.gz", c.SampleNo, safeName(c.Plate), c.VIN, c.StatDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendRole(roles map[string]string, eventID, role string) {
|
||||||
|
if eventID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roles[eventID] == "" {
|
||||||
|
roles[eventID] = role
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roles[eventID] += ";" + role
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) (string, int64, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
size, err := io.Copy(hash, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", hash.Sum(nil)), size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRawArchive(path string) ([]rawFrame, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
gz, err := gzip.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
reader := csv.NewReader(gz)
|
||||||
|
rows, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty raw archive %s", path)
|
||||||
|
}
|
||||||
|
columns := map[string]int{}
|
||||||
|
for index, name := range rows[0] {
|
||||||
|
columns[name] = index
|
||||||
|
}
|
||||||
|
cell := func(row []string, name string) string {
|
||||||
|
index, ok := columns[name]
|
||||||
|
if !ok || index >= len(row) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return row[index]
|
||||||
|
}
|
||||||
|
frames := make([]rawFrame, 0, len(rows)-1)
|
||||||
|
for _, row := range rows[1:] {
|
||||||
|
fields := map[string]any{}
|
||||||
|
if rawFields := cell(row, "完整解析字段JSON"); rawFields != "" {
|
||||||
|
_ = json.Unmarshal([]byte(rawFields), &fields)
|
||||||
|
}
|
||||||
|
messageID, _ := strconv.Atoi(cell(row, "消息ID"))
|
||||||
|
rawSize, _ := strconv.Atoi(cell(row, "原始字节数"))
|
||||||
|
frames = append(frames, rawFrame{
|
||||||
|
FrameID: cell(row, "帧ID"), EventID: cell(row, "事件ID"), MessageID: messageID,
|
||||||
|
MessageIDHex: cell(row, "消息ID_HEX"), EventTime: cell(row, "事件时间"), ReceivedAt: cell(row, "接收时间"),
|
||||||
|
RawSizeBytes: rawSize, RawHex: cell(row, "原始报文HEX"), ParsedFields: fields,
|
||||||
|
ParseStatus: cell(row, "解析状态"), SourceEndpoint: cell(row, "源端点"), Protocol: "GB32960", VIN: cell(row, "VIN"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFrames(vin, statDate string) ([]rawFrame, int, error) {
|
||||||
|
const limit = 500
|
||||||
|
first, err := fetchPage(vin, statDate, 0, limit, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
frames := append([]rawFrame(nil), first.Items...)
|
||||||
|
for offset := limit; offset < first.Total; offset += limit {
|
||||||
|
page, err := fetchPage(vin, statDate, offset, limit, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
frames = append(frames, page.Items...)
|
||||||
|
}
|
||||||
|
if len(frames) != first.Total {
|
||||||
|
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
|
||||||
|
}
|
||||||
|
return frames, first.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPage(vin, statDate string, offset, limit int, includeTotal bool) (rawResponse, error) {
|
||||||
|
q := url.Values{"protocol": {"GB32960"}, "vin": {vin}, "dateFrom": {statDate + " 00:00:00"}, "dateTo": {statDate + " 23:59:59"}, "orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}, "includeFields": {"true"}, "includePayload": {"true"}, "includeTotal": {strconv.FormatBool(includeTotal)}}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+q.Encode(), nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
client := &http.Client{Timeout: 90 * time.Second}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return rawResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
|
||||||
|
return rawResponse{}, nil
|
||||||
|
}
|
||||||
|
return rawResponse{}, fmt.Errorf("HTTP %d: %s", res.StatusCode, body)
|
||||||
|
}
|
||||||
|
var reader io.Reader = res.Body
|
||||||
|
if res.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
gz, err := gzip.NewReader(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return rawResponse{}, err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
reader = gz
|
||||||
|
}
|
||||||
|
var out rawResponse
|
||||||
|
if err := json.NewDecoder(reader).Decode(&out); err != nil {
|
||||||
|
return rawResponse{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func observationFromFrame(f rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool, bool) {
|
||||||
|
if f.MessageID != 2 || f.ParseStatus != "OK" {
|
||||||
|
return openplatform.HydrogenObservation{}, false, false
|
||||||
|
}
|
||||||
|
pressure, pok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||||
|
temp, tok := num(f.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||||
|
critical := pok && tok
|
||||||
|
if !pok || !tok || pressure <= 0 || pressure > 70 || temp <= -40 || temp > 726.85 {
|
||||||
|
return openplatform.HydrogenObservation{}, false, critical
|
||||||
|
}
|
||||||
|
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temp, tankCapacity)
|
||||||
|
if !ok {
|
||||||
|
return openplatform.HydrogenObservation{}, false, critical
|
||||||
|
}
|
||||||
|
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temp, tankCapacity)
|
||||||
|
parsed, _ := json.Marshal(f.ParsedFields)
|
||||||
|
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
|
||||||
|
t, err := parseEventTime(f.EventTime)
|
||||||
|
if err != nil || t.Format("2006-01-02") != statDate {
|
||||||
|
return openplatform.HydrogenObservation{}, false, critical
|
||||||
|
}
|
||||||
|
o := openplatform.HydrogenObservation{VIN: f.VIN, Source: f.SourceEndpoint, EventID: f.EventID, ObservedAt: t, MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temp, NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05), FuelCellActive: active, FuelCellStateKnown: known}
|
||||||
|
if voltage, vok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"]); vok && voltage > 0 && voltage <= 1000 {
|
||||||
|
if current, cok := num(f.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"]); cok && current >= 0 && current <= 2000 {
|
||||||
|
o.FuelCellVoltageV = voltage
|
||||||
|
o.FuelCellCurrentA = current
|
||||||
|
o.FuelCellPowerKnown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := num(f.ParsedFields["gb32960.vehicle.soc_percent"]); ok && v >= 0 && v <= 100 {
|
||||||
|
o.SOCPercent = v
|
||||||
|
o.SOCKnown = true
|
||||||
|
}
|
||||||
|
if v, ok := num(f.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && v >= 0 {
|
||||||
|
o.MileageKm = v
|
||||||
|
o.MileageKnown = true
|
||||||
|
}
|
||||||
|
if v, ok := num(f.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||||
|
o.VehicleState = int(v)
|
||||||
|
o.VehicleStateKnown = v >= 0 && v <= 255
|
||||||
|
}
|
||||||
|
if v, ok := num(f.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||||
|
o.ChargeState = int(v)
|
||||||
|
o.ChargeStateKnown = v >= 0 && v <= 255
|
||||||
|
}
|
||||||
|
if v, ok := num(f.ParsedFields["gb32960.vehicle.running_mode"]); ok {
|
||||||
|
o.RunningMode = int(v)
|
||||||
|
o.RunningModeKnown = v >= 0 && v <= 255
|
||||||
|
}
|
||||||
|
return o, true, critical
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawHeaders = []string{"序号", "车牌", "VIN", "统计日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "事件时间", "接收时间", "帧ID", "事件ID", "消息ID", "消息ID_HEX", "源端点", "解析状态", "原始字节数", "原始报文HEX", "完整解析字段JSON", "最高氢压(MPa)", "最高氢温(℃)", "电池SOC(%)", "仪表总里程(km)", "车辆状态", "充电状态", "运行模式", "燃料电池工作状态", "燃料电池电流(A)", "车端氢气质量(kg)", "系统压力换算剩余氢量(kg)", "噪声阈值(kg)", "是否算法有效样本", "计算角色/排除原因"}
|
||||||
|
|
||||||
|
func writeRawCSV(path string, c candidate, frames []rawFrame, roles map[string]string) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
gz := gzip.NewWriter(f)
|
||||||
|
defer gz.Close()
|
||||||
|
w := csv.NewWriter(gz)
|
||||||
|
defer w.Flush()
|
||||||
|
if err := w.Write(rawHeaders); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rf := range frames {
|
||||||
|
o, valid, _ := observationFromFrame(rf, c.TankCapacityL, c.StatDate)
|
||||||
|
role := roles[rf.EventID]
|
||||||
|
if !valid {
|
||||||
|
if t, err := parseEventTime(rf.EventTime); err == nil && t.Format("2006-01-02") != c.StatDate {
|
||||||
|
role = "事件时间不在统计日,不进入计算"
|
||||||
|
} else if rf.MessageID != 2 {
|
||||||
|
role = "非实时信息上报帧,不进入计算"
|
||||||
|
} else if rf.ParseStatus != "OK" {
|
||||||
|
role = "解析状态非OK,不进入计算"
|
||||||
|
} else {
|
||||||
|
role = "压力/温度无效或缺失,不进入计算"
|
||||||
|
}
|
||||||
|
} else if role == "" {
|
||||||
|
role = "有效候选帧(由分段规则判定)"
|
||||||
|
}
|
||||||
|
parsedJSON, _ := json.Marshal(rf.ParsedFields)
|
||||||
|
row := []string{fmt.Sprintf("%04d", c.SampleNo), c.Plate, c.VIN, c.StatDate, c.Model, strconv.FormatFloat(c.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(c.BatteryKWh, 'f', 2, 64), rf.EventTime, rf.ReceivedAt, rf.FrameID, rf.EventID, strconv.Itoa(rf.MessageID), rf.MessageIDHex, rf.SourceEndpoint, rf.ParseStatus, strconv.Itoa(rf.RawSizeBytes), rf.RawHex, string(parsedJSON), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa"), val(rf.ParsedFields, "gb32960.fuel_cell.max_hydrogen_temperature_c"), val(rf.ParsedFields, "gb32960.vehicle.soc_percent"), val(rf.ParsedFields, "gb32960.vehicle.total_mileage_km"), val(rf.ParsedFields, "gb32960.vehicle.vehicle_status"), val(rf.ParsedFields, "gb32960.vehicle.charge_status"), val(rf.ParsedFields, "gb32960.vehicle.running_mode"), val(rf.ParsedFields, "gb32960.gd_fc_stack.engine_work_state"), val(rf.ParsedFields, "gb32960.fuel_cell.fuel_cell_current_a"), val(rf.ParsedFields, "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"), blankFloat(valid, o.MassKg), blankFloat(valid, o.NoiseKg), yesNo(valid), role}
|
||||||
|
if err := w.Write(row); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeManifest(path string, outputs []dayOutput) {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w := csv.NewWriter(f)
|
||||||
|
defer w.Flush()
|
||||||
|
_ = w.Write([]string{"序号", "车牌", "VIN", "日期", "车型", "储氢总容积(L)", "额定电量(kWh)", "API原始帧数", "唯一帧数", "重复帧数", "算法样本数", "关键字段帧数", "最早事件时间", "最晚事件时间", "原始文件", "压缩包SHA256", "压缩字节数", "质量状态", "质量原因"})
|
||||||
|
for _, o := range outputs {
|
||||||
|
_ = w.Write([]string{fmt.Sprintf("%04d", o.Candidate.SampleNo), o.Candidate.Plate, o.Candidate.VIN, o.Candidate.StatDate, o.Candidate.Model, strconv.FormatFloat(o.Candidate.TankCapacityL, 'f', 2, 64), strconv.FormatFloat(o.Candidate.BatteryKWh, 'f', 2, 64), strconv.Itoa(o.APIRawFrameCount), strconv.Itoa(o.UniqueFrameCount), strconv.Itoa(o.DuplicateFrameCount), strconv.Itoa(o.AlgorithmSamples), strconv.Itoa(o.CriticalFieldRows), o.EarliestEventTime, o.LatestEventTime, o.RawArchiveFile, o.RawArchiveSHA256, strconv.FormatInt(o.RawArchiveBytes, 10), o.Stat.QualityStatus, o.Stat.QualityReason})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func num(v any) (float64, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x, true
|
||||||
|
case string:
|
||||||
|
n, e := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
||||||
|
return n, e == nil
|
||||||
|
case json.Number:
|
||||||
|
n, e := x.Float64()
|
||||||
|
return n, e == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func val(m map[string]any, k string) string {
|
||||||
|
if v, ok := m[k]; ok {
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func blankFloat(ok bool, v float64) string {
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'f', 6, 64)
|
||||||
|
}
|
||||||
|
func yesNo(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
}
|
||||||
|
func safeName(v string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return "无车牌"
|
||||||
|
}
|
||||||
|
return strings.NewReplacer("/", "_", "\\", "_", " ", "_").Replace(v)
|
||||||
|
}
|
||||||
|
func parseEventTime(value string) (time.Time, error) {
|
||||||
|
loc := time.FixedZone("CST", 8*3600)
|
||||||
|
if t, e := time.ParseInLocation("2006-01-02 15:04:05.000", value, loc); e == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
return time.ParseInLocation("2006-01-02 15:04:05", value, loc)
|
||||||
|
}
|
||||||
|
func mustReadJSON(path string, v any) {
|
||||||
|
b, e := os.ReadFile(path)
|
||||||
|
if e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
if e = json.Unmarshal(b, v); e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func writeJSON(path string, v any) {
|
||||||
|
b, e := json.MarshalIndent(v, "", " ")
|
||||||
|
if e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
if e = os.WriteFile(path, b, 0o644); e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func sumRaw(v []dayOutput) int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range v {
|
||||||
|
n += x.APIRawFrameCount
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
func sumSamples(v []dayOutput) int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range v {
|
||||||
|
n += x.AlgorithmSamples
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
func sumIntervals(v []dayOutput) int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range v {
|
||||||
|
n += len(x.Stat.Intervals)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args)!=3 { panic("usage: query_capacity <selected.json> <output.json>") }
|
||||||
|
var selected []struct{ VIN string `json:"vin"` }
|
||||||
|
b,err:=os.ReadFile(os.Args[1]);if err!=nil{panic(err)};if err=json.Unmarshal(b,&selected);err!=nil{panic(err)}
|
||||||
|
prefix := "HYDROGEN_DB_"
|
||||||
|
host := strings.TrimSpace(os.Getenv(prefix + "HOST"))
|
||||||
|
port := strings.TrimSpace(os.Getenv(prefix + "PORT")); if port == "" { port = "3306" }
|
||||||
|
name := strings.TrimSpace(os.Getenv(prefix + "NAME"))
|
||||||
|
user := os.Getenv(prefix + "USER"); pass := os.Getenv(prefix + "PASSWORD")
|
||||||
|
dsn := user+":"+pass+"@tcp("+net.JoinHostPort(host,port)+")/"+name+"?parseTime=true&loc="+url.QueryEscape("Asia/Shanghai")+"&timeout=5s&readTimeout=15s"
|
||||||
|
db,err:=sql.Open("mysql",dsn); if err!=nil{panic(err)}; defer db.Close(); db.SetConnMaxLifetime(time.Minute); if err=db.Ping();err!=nil{panic(err)}
|
||||||
|
vins:=make([]string,0,len(selected));args:=make([]any,0,len(selected));for _,s:=range selected{v:=strings.ToUpper(strings.TrimSpace(s.VIN));vins=append(vins,v);args=append(args,v)}
|
||||||
|
placeholders:=strings.TrimRight(strings.Repeat("?,",len(vins)),",")
|
||||||
|
rows,err:=db.Query(`SELECT UPPER(TRIM(vin)),plate_number,brand_name,model_name,tank_capacity_l,active,source_model_id,source_updated_at,synced_at FROM lingniu_vehicle_data.vehicle_hydrogen_tank_capacity WHERE UPPER(TRIM(vin)) IN (`+placeholders+`) ORDER BY vin`,args...);if err!=nil{panic(err)};defer rows.Close()
|
||||||
|
type row struct{VIN,Plate,Brand,Model string;TankCapacityL float64;Active int;SourceModelID *int64;SourceUpdatedAt, SyncedAt *time.Time}
|
||||||
|
out:=make([]row,0,len(vins));for rows.Next(){var r row;if err=rows.Scan(&r.VIN,&r.Plate,&r.Brand,&r.Model,&r.TankCapacityL,&r.Active,&r.SourceModelID,&r.SourceUpdatedAt,&r.SyncedAt);err!=nil{panic(err)};out=append(out,r)};if err=rows.Err();err!=nil{panic(err)}
|
||||||
|
sort.Slice(out,func(i,j int)bool{return out[i].VIN<out[j].VIN});data,err:=json.MarshalIndent(out,""," ");if err!=nil{panic(err)};if err=os.WriteFile(os.Args[2],data,0o644);err!=nil{panic(err)}
|
||||||
|
counts:=map[float64]int{};models:=map[string]int{};for _,r:=range out{counts[r.TankCapacityL]++;models[r.Model]++}
|
||||||
|
fmt.Printf("requested=%d found=%d capacities=%v models=%v\n",len(vins),len(out),counts,models)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type metadataRecord struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
VehicleModelID int64 `json:"vehicle_model_id"`
|
||||||
|
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||||
|
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||||
|
SourceUpdatedAt *time.Time `json:"source_updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) != 2 {
|
||||||
|
panic("usage: query_metadata <output.json>")
|
||||||
|
}
|
||||||
|
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||||
|
if dsn == "" {
|
||||||
|
panic("MYSQL_DSN is required")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
rows, err := db.Query(`SELECT UPPER(TRIM(vi.vin)),COALESCE(vi.plate_number,''),
|
||||||
|
COALESCE(vm.brand,''),COALESCE(vm.model,''),vm.id,vm.battery_capacity,vm.tank_capacity,
|
||||||
|
CASE
|
||||||
|
WHEN vi.update_time IS NULL THEN vm.update_time
|
||||||
|
WHEN vm.update_time IS NULL THEN vi.update_time
|
||||||
|
ELSE GREATEST(vi.update_time,vm.update_time)
|
||||||
|
END
|
||||||
|
FROM ln_asset_management.vehicle_info vi
|
||||||
|
JOIN ln_asset_management.vehicle_model vm ON vm.id=vi.vehicle_model_id
|
||||||
|
WHERE COALESCE(vi.del_flag,'0')='0' AND COALESCE(vm.del_flag,'0')='0'
|
||||||
|
AND LOWER(TRIM(vm.brand))='hyundai'
|
||||||
|
AND LENGTH(TRIM(vi.vin))=17
|
||||||
|
AND vm.tank_capacity>0
|
||||||
|
AND vm.battery_capacity>0
|
||||||
|
ORDER BY vi.vin,vi.update_time DESC,vi.id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
byVIN := map[string]metadataRecord{}
|
||||||
|
for rows.Next() {
|
||||||
|
var row metadataRecord
|
||||||
|
var updated sql.NullTime
|
||||||
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Brand, &row.Model, &row.VehicleModelID, &row.BatteryCapacityKWh, &row.TankCapacityL, &updated); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if _, exists := byVIN[row.VIN]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if updated.Valid {
|
||||||
|
value := updated.Time
|
||||||
|
row.SourceUpdatedAt = &value
|
||||||
|
}
|
||||||
|
byVIN[row.VIN] = row
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
vins := make([]string, 0, len(byVIN))
|
||||||
|
for vin := range byVIN {
|
||||||
|
vins = append(vins, vin)
|
||||||
|
}
|
||||||
|
sort.Strings(vins)
|
||||||
|
out := make([]metadataRecord, 0, len(vins))
|
||||||
|
for _, vin := range vins {
|
||||||
|
out = append(out, byVIN[vin])
|
||||||
|
}
|
||||||
|
output, err := os.Create(os.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer output.Close()
|
||||||
|
encoder := json.NewEncoder(output)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(out); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
baseURL = "http://115.29.187.205:20200"
|
||||||
|
hydrogenEnergyKWh = 16.0
|
||||||
|
maxIntegrationGap = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type vehicleMetadata struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
BatteryCapacityKWh float64 `json:"battery_capacity_kwh"`
|
||||||
|
TankCapacityL float64 `json:"tank_capacity_l"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawFrame struct {
|
||||||
|
TS string `json:"ts"`
|
||||||
|
FrameID string `json:"frame_id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
EventTime string `json:"event_time"`
|
||||||
|
ParseStatus string `json:"parse_status"`
|
||||||
|
SourceEndpoint string `json:"source_endpoint"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
ParsedFields map[string]any `json:"parsed_fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawResponse struct {
|
||||||
|
Items []rawFrame `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type energySample struct {
|
||||||
|
At time.Time
|
||||||
|
VehicleStatus int
|
||||||
|
ChargeStatus int
|
||||||
|
SOC float64
|
||||||
|
Mileage float64
|
||||||
|
BatteryVoltageV float64
|
||||||
|
BatteryCurrentA float64
|
||||||
|
FuelCellVoltageV float64
|
||||||
|
FuelCellCurrentA float64
|
||||||
|
HasSOC bool
|
||||||
|
HasMileage bool
|
||||||
|
HasBatteryPower bool
|
||||||
|
HasFuelCellPower bool
|
||||||
|
HasVehicleStatus bool
|
||||||
|
HasChargeStatus bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type energyResult struct {
|
||||||
|
FirstTime string `json:"firstTime"`
|
||||||
|
LastTime string `json:"lastTime"`
|
||||||
|
StartSOC float64 `json:"startSoc"`
|
||||||
|
EndSOC float64 `json:"endSoc"`
|
||||||
|
SOCChangePctEndMinusStart float64 `json:"socChangePctEndMinusStart"`
|
||||||
|
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
|
||||||
|
BatteryNetOutputKWhSOC float64 `json:"batteryNetOutputKWhSoc"`
|
||||||
|
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
|
||||||
|
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
|
||||||
|
BatteryCoverageSeconds float64 `json:"batteryCoverageSeconds"`
|
||||||
|
FuelCellCoverageSeconds float64 `json:"fuelCellCoverageSeconds"`
|
||||||
|
OperatingSpanSeconds float64 `json:"operatingSpanSeconds"`
|
||||||
|
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
|
||||||
|
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
|
||||||
|
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
|
||||||
|
EnergySampleCount int `json:"energySampleCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
RawFrameCount int `json:"rawFrameCount"`
|
||||||
|
CurrentQuality string `json:"currentQuality"`
|
||||||
|
CurrentReason string `json:"currentReason"`
|
||||||
|
RefuelCount int `json:"refuelCount"`
|
||||||
|
ChargeCount int `json:"chargeCount"`
|
||||||
|
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||||
|
PureElectricMileageKm float64 `json:"pureElectricMileageKm"`
|
||||||
|
CurrentMixedMileageKm float64 `json:"currentMixedMileageKm"`
|
||||||
|
PhysicalHydrogenKg float64 `json:"physicalHydrogenKg"`
|
||||||
|
CurrentSOCBalancedKg float64 `json:"currentSocBalancedKg"`
|
||||||
|
CurrentRatePerMixedKm float64 `json:"currentRatePerMixedKm"`
|
||||||
|
PhysicalRatePerTotalKm float64 `json:"physicalRatePerTotalKm"`
|
||||||
|
FullDayStartSOC float64 `json:"fullDayStartSoc"`
|
||||||
|
FullDayEndSOC float64 `json:"fullDayEndSoc"`
|
||||||
|
FullDaySOCChangePct float64 `json:"fullDaySocChangePct"`
|
||||||
|
StoredEnergyChangeKWhSOC float64 `json:"storedEnergyChangeKWhSoc"`
|
||||||
|
BatteryEquivalentKgFixed16 float64 `json:"batteryEquivalentKgFixed16"`
|
||||||
|
StandardLikeBalancedKg float64 `json:"standardLikeBalancedKg"`
|
||||||
|
StandardLikeRatePerTotalKm float64 `json:"standardLikeRatePerTotalKm"`
|
||||||
|
StandardLikeApplicable bool `json:"standardLikeApplicable"`
|
||||||
|
StandardLikeReason string `json:"standardLikeReason"`
|
||||||
|
BatteryNetOutputKWhIntegrated float64 `json:"batteryNetOutputKWhIntegrated"`
|
||||||
|
FuelCellOutputKWhIntegrated float64 `json:"fuelCellOutputKWhIntegrated"`
|
||||||
|
BatteryEnergyShare float64 `json:"batteryEnergyShare"`
|
||||||
|
FuelCellEnergyShare float64 `json:"fuelCellEnergyShare"`
|
||||||
|
BatteryContributionKm float64 `json:"batteryContributionKm"`
|
||||||
|
FuelCellContributionKm float64 `json:"fuelCellContributionKm"`
|
||||||
|
HydrogenRatePerFCContributionKm float64 `json:"hydrogenRatePerFcContributionKm"`
|
||||||
|
BatteryCoverageRatio float64 `json:"batteryCoverageRatio"`
|
||||||
|
FuelCellCoverageRatio float64 `json:"fuelCellCoverageRatio"`
|
||||||
|
ExternalChargeFrameCount int `json:"externalChargeFrameCount"`
|
||||||
|
PowerIntegrationUsable bool `json:"powerIntegrationUsable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type job struct {
|
||||||
|
Vehicle vehicleMetadata
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) != 5 {
|
||||||
|
panic("usage: standard-model-trial <metadata.json> <start-date> <end-date> <output-dir>")
|
||||||
|
}
|
||||||
|
metadataPath, startDate, endDate, outputDir := os.Args[1], os.Args[2], os.Args[3], os.Args[4]
|
||||||
|
start := mustDate(startDate)
|
||||||
|
end := mustDate(endDate)
|
||||||
|
if end.Before(start) {
|
||||||
|
panic("end date is before start date")
|
||||||
|
}
|
||||||
|
var vehicles []vehicleMetadata
|
||||||
|
mustReadJSON(metadataPath, &vehicles)
|
||||||
|
if len(vehicles) == 0 {
|
||||||
|
panic("metadata is empty")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := make(chan job)
|
||||||
|
results := make(chan row)
|
||||||
|
errs := make(chan error, 128)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for worker := 0; worker < 16; worker++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for item := range jobs {
|
||||||
|
result, err := process(item)
|
||||||
|
if err != nil {
|
||||||
|
errs <- fmt.Errorf("%s %s %s: %w", item.Date, item.Vehicle.Plate, item.Vehicle.VIN, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.RawFrameCount > 0 {
|
||||||
|
results <- result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||||
|
for _, vehicle := range vehicles {
|
||||||
|
if len(strings.TrimSpace(vehicle.VIN)) != 17 || vehicle.TankCapacityL <= 0 || vehicle.BatteryCapacityKWh <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobs <- job{Vehicle: vehicle, Date: day.Format("2006-01-02")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
close(errs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
completed := 0
|
||||||
|
for result := range results {
|
||||||
|
rows = append(rows, result)
|
||||||
|
completed++
|
||||||
|
if completed%25 == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "completed=%d latest=%s %s quality=%s raw=%d\n", completed, result.Date, result.Plate, result.CurrentQuality, result.RawFrameCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var errorMessages []string
|
||||||
|
for err := range errs {
|
||||||
|
errorMessages = append(errorMessages, err.Error())
|
||||||
|
}
|
||||||
|
if len(errorMessages) > 0 {
|
||||||
|
panic(strings.Join(errorMessages, "\n"))
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Date != rows[j].Date {
|
||||||
|
return rows[i].Date < rows[j].Date
|
||||||
|
}
|
||||||
|
if rows[i].Plate != rows[j].Plate {
|
||||||
|
return rows[i].Plate < rows[j].Plate
|
||||||
|
}
|
||||||
|
return rows[i].VIN < rows[j].VIN
|
||||||
|
})
|
||||||
|
writeJSON(filepath.Join(outputDir, "results.json"), rows)
|
||||||
|
writeCSV(filepath.Join(outputDir, "results.csv"), rows)
|
||||||
|
writeJSON(filepath.Join(outputDir, "summary.json"), summarize(rows))
|
||||||
|
fmt.Printf("vehicle_days=%d output=%s\n", len(rows), outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func process(item job) (row, error) {
|
||||||
|
frames, total, err := fetchFrames(item.Vehicle.VIN, item.Date)
|
||||||
|
if err != nil || total == 0 {
|
||||||
|
return row{Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model, RawFrameCount: total}, err
|
||||||
|
}
|
||||||
|
sort.SliceStable(frames, func(i, j int) bool {
|
||||||
|
if frames[i].EventTime != frames[j].EventTime {
|
||||||
|
return frames[i].EventTime < frames[j].EventTime
|
||||||
|
}
|
||||||
|
return frames[i].EventID < frames[j].EventID
|
||||||
|
})
|
||||||
|
observations := make([]openplatform.HydrogenObservation, 0, len(frames))
|
||||||
|
energySamples := make([]energySample, 0, len(frames))
|
||||||
|
for _, frame := range frames {
|
||||||
|
if observation, ok := observationFromFrame(frame, item.Vehicle.TankCapacityL, item.Date); ok {
|
||||||
|
observations = append(observations, observation)
|
||||||
|
}
|
||||||
|
if sample, ok := energySampleFromFrame(frame, item.Date); ok {
|
||||||
|
energySamples = append(energySamples, sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params := map[string]openplatform.HydrogenCalculationParameters{
|
||||||
|
item.Vehicle.VIN: {BatteryCapacityKWh: item.Vehicle.BatteryCapacityKWh, HydrogenEnergyKWhKg: hydrogenEnergyKWh},
|
||||||
|
}
|
||||||
|
stats := openplatform.BuildHydrogenDailyStatsOrderedWithParameters(observations, item.Date, 0.05, 20, params)
|
||||||
|
stat := openplatform.HydrogenDailyStat{VIN: item.Vehicle.VIN, Date: item.Date, QualityStatus: "NO_DATA", QualityReason: "无有效压力温度样本"}
|
||||||
|
if len(stats) == 1 {
|
||||||
|
stat = stats[0]
|
||||||
|
}
|
||||||
|
energy := calculateEnergy(energySamples, item.Vehicle.BatteryCapacityKWh)
|
||||||
|
totalMileage := stat.MixedMileageKm + stat.PureElectricMileageKm
|
||||||
|
result := row{
|
||||||
|
Date: item.Date, Plate: item.Vehicle.Plate, VIN: item.Vehicle.VIN, Model: item.Vehicle.Model,
|
||||||
|
RawFrameCount: total, CurrentQuality: stat.QualityStatus, CurrentReason: stat.QualityReason,
|
||||||
|
RefuelCount: stat.RefuelCount, ChargeCount: stat.ChargeCount,
|
||||||
|
TotalMileageKm: totalMileage, PureElectricMileageKm: stat.PureElectricMileageKm,
|
||||||
|
CurrentMixedMileageKm: stat.MixedMileageKm, PhysicalHydrogenKg: stat.ConsumptionKg,
|
||||||
|
FullDayStartSOC: energy.StartSOC, FullDayEndSOC: energy.EndSOC,
|
||||||
|
FullDaySOCChangePct: energy.SOCChangePctEndMinusStart,
|
||||||
|
StoredEnergyChangeKWhSOC: energy.StoredEnergyChangeKWhSOC,
|
||||||
|
BatteryNetOutputKWhIntegrated: energy.BatteryNetOutputKWhIntegrated,
|
||||||
|
FuelCellOutputKWhIntegrated: energy.FuelCellOutputKWhIntegrated,
|
||||||
|
BatteryCoverageRatio: energy.BatteryCoverageRatio,
|
||||||
|
FuelCellCoverageRatio: energy.FuelCellCoverageRatio,
|
||||||
|
ExternalChargeFrameCount: energy.ExternalChargeFrameCount,
|
||||||
|
}
|
||||||
|
if stat.SOCBalancedConsumptionKg != nil {
|
||||||
|
result.CurrentSOCBalancedKg = *stat.SOCBalancedConsumptionKg
|
||||||
|
}
|
||||||
|
if stat.SOCBalancedKgPer100Km != nil {
|
||||||
|
result.CurrentRatePerMixedKm = *stat.SOCBalancedKgPer100Km
|
||||||
|
}
|
||||||
|
if totalMileage > 0 {
|
||||||
|
result.PhysicalRatePerTotalKm = round(stat.ConsumptionKg * 100 / totalMileage)
|
||||||
|
result.BatteryEquivalentKgFixed16 = round(-energy.StoredEnergyChangeKWhSOC / hydrogenEnergyKWh)
|
||||||
|
result.StandardLikeBalancedKg = round(stat.ConsumptionKg + result.BatteryEquivalentKgFixed16)
|
||||||
|
result.StandardLikeRatePerTotalKm = round(result.StandardLikeBalancedKg * 100 / totalMileage)
|
||||||
|
switch {
|
||||||
|
case energy.ExternalChargeFrameCount > 0:
|
||||||
|
result.StandardLikeReason = "检测到外部充电,整日SOC平衡修正不适用,应按CD/CS分段"
|
||||||
|
case result.StandardLikeBalancedKg < 0:
|
||||||
|
result.StandardLikeReason = "SOC平衡氢量为负,端点、SOC或换算系数需复核"
|
||||||
|
default:
|
||||||
|
result.StandardLikeApplicable = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.StandardLikeReason = "无有效总里程"
|
||||||
|
}
|
||||||
|
// The GB/T 43252 contribution trial uses positive battery net output and
|
||||||
|
// fuel-cell output measured over non-external-charge vehicle-on intervals.
|
||||||
|
// Coverage gates prevent sparse power fields from being treated as precise.
|
||||||
|
if totalMileage >= 10 && stat.ConsumptionKg > 0 && energy.ExternalChargeFrameCount == 0 && energy.BatteryCoverageRatio >= 0.98 && energy.FuelCellCoverageRatio >= 0.98 && energy.FuelCellOutputKWhIntegrated > 0 {
|
||||||
|
batteryOutput := math.Max(0, energy.BatteryNetOutputKWhIntegrated)
|
||||||
|
totalOutput := batteryOutput + energy.FuelCellOutputKWhIntegrated
|
||||||
|
if totalOutput > 0 {
|
||||||
|
result.PowerIntegrationUsable = true
|
||||||
|
result.BatteryEnergyShare = round(batteryOutput / totalOutput)
|
||||||
|
result.FuelCellEnergyShare = round(energy.FuelCellOutputKWhIntegrated / totalOutput)
|
||||||
|
result.BatteryContributionKm = round(totalMileage * result.BatteryEnergyShare)
|
||||||
|
result.FuelCellContributionKm = round(totalMileage * result.FuelCellEnergyShare)
|
||||||
|
if result.FuelCellContributionKm > 0 {
|
||||||
|
result.HydrogenRatePerFCContributionKm = round(stat.ConsumptionKg * 100 / result.FuelCellContributionKm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateEnergy(samples []energySample, batteryCapacityKWh float64) energyResult {
|
||||||
|
result := energyResult{EnergySampleCount: len(samples)}
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, sample := range samples {
|
||||||
|
if sample.HasChargeStatus && sample.ChargeStatus == 1 {
|
||||||
|
result.ExternalChargeFrameCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operating := make([]energySample, 0, len(samples))
|
||||||
|
for _, sample := range samples {
|
||||||
|
if sample.HasVehicleStatus && sample.VehicleStatus == 1 && (!sample.HasChargeStatus || sample.ChargeStatus != 1) {
|
||||||
|
operating = append(operating, sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(operating) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.FirstTime = operating[0].At.Format(time.RFC3339)
|
||||||
|
result.LastTime = operating[len(operating)-1].At.Format(time.RFC3339)
|
||||||
|
result.OperatingSpanSeconds = operating[len(operating)-1].At.Sub(operating[0].At).Seconds()
|
||||||
|
for _, sample := range operating {
|
||||||
|
if sample.HasSOC {
|
||||||
|
result.StartSOC = sample.SOC
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := len(operating) - 1; index >= 0; index-- {
|
||||||
|
if operating[index].HasSOC {
|
||||||
|
result.EndSOC = operating[index].SOC
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.SOCChangePctEndMinusStart = round(result.EndSOC - result.StartSOC)
|
||||||
|
result.StoredEnergyChangeKWhSOC = round(batteryCapacityKWh * result.SOCChangePctEndMinusStart / 100)
|
||||||
|
result.BatteryNetOutputKWhSOC = round(-result.StoredEnergyChangeKWhSOC)
|
||||||
|
for index := 1; index < len(operating); index++ {
|
||||||
|
previous, current := operating[index-1], operating[index]
|
||||||
|
delta := current.At.Sub(previous.At)
|
||||||
|
if delta <= 0 || delta > maxIntegrationGap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hours := delta.Hours()
|
||||||
|
if previous.HasBatteryPower && current.HasBatteryPower {
|
||||||
|
power0 := previous.BatteryVoltageV * previous.BatteryCurrentA / 1000
|
||||||
|
power1 := current.BatteryVoltageV * current.BatteryCurrentA / 1000
|
||||||
|
result.BatteryNetOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
|
||||||
|
result.BatteryCoverageSeconds += delta.Seconds()
|
||||||
|
}
|
||||||
|
if previous.HasFuelCellPower && current.HasFuelCellPower {
|
||||||
|
power0 := previous.FuelCellVoltageV * previous.FuelCellCurrentA / 1000
|
||||||
|
power1 := current.FuelCellVoltageV * current.FuelCellCurrentA / 1000
|
||||||
|
result.FuelCellOutputKWhIntegrated += (power0 + power1) * 0.5 * hours
|
||||||
|
result.FuelCellCoverageSeconds += delta.Seconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.BatteryNetOutputKWhIntegrated = round(result.BatteryNetOutputKWhIntegrated)
|
||||||
|
result.FuelCellOutputKWhIntegrated = round(result.FuelCellOutputKWhIntegrated)
|
||||||
|
if result.OperatingSpanSeconds > 0 {
|
||||||
|
result.BatteryCoverageRatio = round(result.BatteryCoverageSeconds / result.OperatingSpanSeconds)
|
||||||
|
result.FuelCellCoverageRatio = round(result.FuelCellCoverageSeconds / result.OperatingSpanSeconds)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func observationFromFrame(frame rawFrame, tankCapacity float64, statDate string) (openplatform.HydrogenObservation, bool) {
|
||||||
|
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
|
||||||
|
return openplatform.HydrogenObservation{}, false
|
||||||
|
}
|
||||||
|
pressure, pressureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||||
|
temperature, temperatureOK := number(frame.ParsedFields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||||
|
if !pressureOK || !temperatureOK || pressure <= 0 || pressure > 70 || temperature <= -40 || temperature > 726.85 {
|
||||||
|
return openplatform.HydrogenObservation{}, false
|
||||||
|
}
|
||||||
|
mass, ok := openplatform.PressureHydrogenMassKg(pressure, temperature, tankCapacity)
|
||||||
|
if !ok {
|
||||||
|
return openplatform.HydrogenObservation{}, false
|
||||||
|
}
|
||||||
|
step, _ := openplatform.PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, tankCapacity)
|
||||||
|
at, err := parseEventTime(frame.EventTime)
|
||||||
|
if err != nil || at.Format("2006-01-02") != statDate {
|
||||||
|
return openplatform.HydrogenObservation{}, false
|
||||||
|
}
|
||||||
|
parsed, _ := json.Marshal(frame.ParsedFields)
|
||||||
|
_, _, active, known, _ := openplatform.ExtractHydrogenTelemetry(string(parsed))
|
||||||
|
observation := openplatform.HydrogenObservation{
|
||||||
|
VIN: frame.VIN, Source: frame.SourceEndpoint, EventID: frame.EventID, ObservedAt: at,
|
||||||
|
MassKg: mass, TankCapacityLiter: tankCapacity, PressureMPa: pressure, TemperatureC: temperature,
|
||||||
|
NoiseKg: math.Min(1, math.Max(0.05, mass-step)), RefuelThresholdKg: math.Max(1, mass*0.05),
|
||||||
|
FuelCellActive: active, FuelCellStateKnown: known,
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
|
||||||
|
observation.SOCPercent, observation.SOCKnown = value, true
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
|
||||||
|
observation.MileageKm, observation.MileageKnown = value, true
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||||
|
observation.VehicleState, observation.VehicleStateKnown = int(value), value >= 0 && value <= 255
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||||
|
observation.ChargeState, observation.ChargeStateKnown = int(value), value >= 0 && value <= 255
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.running_mode"]); ok {
|
||||||
|
observation.RunningMode, observation.RunningModeKnown = int(value), value >= 0 && value <= 255
|
||||||
|
}
|
||||||
|
return observation, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func energySampleFromFrame(frame rawFrame, statDate string) (energySample, bool) {
|
||||||
|
if frame.MessageID != 2 || !strings.EqualFold(frame.ParseStatus, "OK") {
|
||||||
|
return energySample{}, false
|
||||||
|
}
|
||||||
|
at, err := parseEventTime(frame.EventTime)
|
||||||
|
if err != nil || at.Format("2006-01-02") != statDate {
|
||||||
|
return energySample{}, false
|
||||||
|
}
|
||||||
|
sample := energySample{At: at}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.vehicle_status"]); ok {
|
||||||
|
sample.VehicleStatus, sample.HasVehicleStatus = int(value), true
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.charge_status"]); ok {
|
||||||
|
sample.ChargeStatus, sample.HasChargeStatus = int(value), true
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.soc_percent"]); ok && value >= 0 && value <= 100 {
|
||||||
|
sample.SOC, sample.HasSOC = value, true
|
||||||
|
}
|
||||||
|
if value, ok := number(frame.ParsedFields["gb32960.vehicle.total_mileage_km"]); ok && value >= 0 {
|
||||||
|
sample.Mileage, sample.HasMileage = value, true
|
||||||
|
}
|
||||||
|
batteryVoltage, batteryVoltageOK := number(frame.ParsedFields["gb32960.vehicle.total_voltage_v"])
|
||||||
|
batteryCurrent, batteryCurrentOK := number(frame.ParsedFields["gb32960.vehicle.total_current_a"])
|
||||||
|
if batteryVoltageOK && batteryCurrentOK && batteryVoltage > 0 && batteryVoltage <= 1000 && batteryCurrent >= -1000 && batteryCurrent <= 1000 {
|
||||||
|
sample.BatteryVoltageV, sample.BatteryCurrentA, sample.HasBatteryPower = batteryVoltage, batteryCurrent, true
|
||||||
|
}
|
||||||
|
fuelCellVoltage, fuelCellVoltageOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_voltage_v"])
|
||||||
|
fuelCellCurrent, fuelCellCurrentOK := number(frame.ParsedFields["gb32960.fuel_cell.fuel_cell_current_a"])
|
||||||
|
if fuelCellVoltageOK && fuelCellCurrentOK && fuelCellVoltage >= 0 && fuelCellVoltage <= 2000 && fuelCellCurrent >= 0 && fuelCellCurrent <= 2000 {
|
||||||
|
sample.FuelCellVoltageV, sample.FuelCellCurrentA, sample.HasFuelCellPower = fuelCellVoltage, fuelCellCurrent, true
|
||||||
|
}
|
||||||
|
return sample, sample.HasVehicleStatus || sample.HasSOC || sample.HasBatteryPower || sample.HasFuelCellPower
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFrames(vin, date string) ([]rawFrame, int, error) {
|
||||||
|
const limit = 500
|
||||||
|
first, err := fetchPage(vin, date, 0, limit, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
frames := append([]rawFrame(nil), first.Items...)
|
||||||
|
for offset := limit; offset < first.Total; offset += limit {
|
||||||
|
page, err := fetchPage(vin, date, offset, limit, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, first.Total, err
|
||||||
|
}
|
||||||
|
frames = append(frames, page.Items...)
|
||||||
|
}
|
||||||
|
if len(frames) != first.Total {
|
||||||
|
return nil, first.Total, fmt.Errorf("API total=%d fetched=%d", first.Total, len(frames))
|
||||||
|
}
|
||||||
|
return frames, first.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPage(vin, date string, offset, limit int, includeTotal bool) (rawResponse, error) {
|
||||||
|
query := url.Values{
|
||||||
|
"protocol": {"GB32960"}, "vin": {vin},
|
||||||
|
"dateFrom": {date + " 00:00:00"}, "dateTo": {date + " 23:59:59"},
|
||||||
|
"orderBy": {"eventTime"}, "limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)},
|
||||||
|
"includeFields": {"true"}, "includePayload": {"false"}, "includeTotal": {strconv.FormatBool(includeTotal)},
|
||||||
|
}
|
||||||
|
var response *http.Response
|
||||||
|
var err error
|
||||||
|
for attempt := 1; attempt <= 4; attempt++ {
|
||||||
|
request, _ := http.NewRequest(http.MethodGet, baseURL+"/api/history/raw-frames?"+query.Encode(), nil)
|
||||||
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
response, err = (&http.Client{Timeout: 90 * time.Second}).Do(request)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if attempt < 4 {
|
||||||
|
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return rawResponse{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
if response.StatusCode == http.StatusInternalServerError && bytes.Contains(body, []byte("Table does not exist")) {
|
||||||
|
return rawResponse{}, nil
|
||||||
|
}
|
||||||
|
return rawResponse{}, fmt.Errorf("HTTP %d: %s", response.StatusCode, body)
|
||||||
|
}
|
||||||
|
var reader io.Reader = response.Body
|
||||||
|
if response.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
gzipReader, err := gzip.NewReader(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return rawResponse{}, err
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
reader = gzipReader
|
||||||
|
}
|
||||||
|
var result rawResponse
|
||||||
|
if err := json.NewDecoder(reader).Decode(&result); err != nil {
|
||||||
|
return rawResponse{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarize(rows []row) map[string]any {
|
||||||
|
byDate := map[string]map[string]any{}
|
||||||
|
for _, date := range uniqueDates(rows) {
|
||||||
|
var dateRows []row
|
||||||
|
for _, value := range rows {
|
||||||
|
if value.Date == date {
|
||||||
|
dateRows = append(dateRows, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quality := map[string]int{}
|
||||||
|
valid := 0
|
||||||
|
standardApplicable := 0
|
||||||
|
powerUsable := 0
|
||||||
|
physicalTotal, balancedTotal, mileageTotal, balancedMileageTotal := 0.0, 0.0, 0.0, 0.0
|
||||||
|
for _, value := range dateRows {
|
||||||
|
quality[value.CurrentQuality]++
|
||||||
|
if value.CurrentQuality != "NO_DATA" && value.TotalMileageKm > 0 {
|
||||||
|
valid++
|
||||||
|
physicalTotal += value.PhysicalHydrogenKg
|
||||||
|
mileageTotal += value.TotalMileageKm
|
||||||
|
}
|
||||||
|
if value.StandardLikeApplicable {
|
||||||
|
standardApplicable++
|
||||||
|
balancedTotal += value.StandardLikeBalancedKg
|
||||||
|
balancedMileageTotal += value.TotalMileageKm
|
||||||
|
}
|
||||||
|
if value.PowerIntegrationUsable {
|
||||||
|
powerUsable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
physicalRate, balancedRate := 0.0, 0.0
|
||||||
|
if mileageTotal > 0 {
|
||||||
|
physicalRate = round(physicalTotal * 100 / mileageTotal)
|
||||||
|
}
|
||||||
|
if balancedMileageTotal > 0 {
|
||||||
|
balancedRate = round(balancedTotal * 100 / balancedMileageTotal)
|
||||||
|
}
|
||||||
|
byDate[date] = map[string]any{
|
||||||
|
"vehicleDaysWithFrames": len(dateRows), "validVehicleDays": valid, "standardLikeApplicableVehicleDays": standardApplicable, "qualityCounts": quality,
|
||||||
|
"powerIntegrationUsableVehicleDays": powerUsable, "totalMileageKm": round(mileageTotal),
|
||||||
|
"physicalHydrogenKg": round(physicalTotal), "standardLikeBalancedHydrogenKg": round(balancedTotal),
|
||||||
|
"standardLikeMileageKm": round(balancedMileageTotal), "fleetPhysicalRateKgPer100Km": physicalRate, "fleetStandardLikeRateKgPer100Km": balancedRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"vehicleDays": len(rows), "byDate": byDate}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCSV(path string, rows []row) {
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
writer := csv.NewWriter(file)
|
||||||
|
defer writer.Flush()
|
||||||
|
headers := []string{"日期", "车牌", "VIN", "车型", "原始帧数", "当前质量状态", "当前质量原因", "加氢次数", "充电次数", "总里程km", "当前纯电里程km", "当前混动里程km", "物理耗氢kg", "当前SOC平衡氢量kg", "当前修正氢耗_按混动里程", "物理氢耗_按总里程", "全日起始SOC", "全日终止SOC", "全日SOC变化_末减初", "全日电池储能变化kWh", "电池等效氢量_16kWhkg", "标准式平衡氢量kg", "标准式平衡氢耗_按总里程", "标准式修正可用", "标准式修正说明", "电流积分电池净输出kWh", "燃料电池输出积分kWh", "电池能量贡献占比", "燃料电池能量贡献占比", "电池贡献里程km", "燃料电池贡献里程km", "按燃料电池贡献里程氢耗", "电池功率覆盖率", "燃料电池功率覆盖率", "外充状态帧数", "功率积分可用"}
|
||||||
|
_ = writer.Write(headers)
|
||||||
|
for _, value := range rows {
|
||||||
|
_ = writer.Write([]string{
|
||||||
|
value.Date, value.Plate, value.VIN, value.Model, integer(value.RawFrameCount), value.CurrentQuality, value.CurrentReason,
|
||||||
|
integer(value.RefuelCount), integer(value.ChargeCount), decimal(value.TotalMileageKm), decimal(value.PureElectricMileageKm),
|
||||||
|
decimal(value.CurrentMixedMileageKm), decimal(value.PhysicalHydrogenKg), decimal(value.CurrentSOCBalancedKg),
|
||||||
|
decimal(value.CurrentRatePerMixedKm), decimal(value.PhysicalRatePerTotalKm), decimal(value.FullDayStartSOC),
|
||||||
|
decimal(value.FullDayEndSOC), decimal(value.FullDaySOCChangePct), decimal(value.StoredEnergyChangeKWhSOC),
|
||||||
|
decimal(value.BatteryEquivalentKgFixed16), decimal(value.StandardLikeBalancedKg), decimal(value.StandardLikeRatePerTotalKm),
|
||||||
|
strconv.FormatBool(value.StandardLikeApplicable), value.StandardLikeReason,
|
||||||
|
decimal(value.BatteryNetOutputKWhIntegrated), decimal(value.FuelCellOutputKWhIntegrated), decimal(value.BatteryEnergyShare),
|
||||||
|
decimal(value.FuelCellEnergyShare), decimal(value.BatteryContributionKm), decimal(value.FuelCellContributionKm),
|
||||||
|
decimal(value.HydrogenRatePerFCContributionKm), decimal(value.BatteryCoverageRatio), decimal(value.FuelCellCoverageRatio),
|
||||||
|
integer(value.ExternalChargeFrameCount), strconv.FormatBool(value.PowerIntegrationUsable),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueDates(rows []row) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range rows {
|
||||||
|
seen[value.Date] = true
|
||||||
|
}
|
||||||
|
dates := make([]string, 0, len(seen))
|
||||||
|
for date := range seen {
|
||||||
|
dates = append(dates, date)
|
||||||
|
}
|
||||||
|
sort.Strings(dates)
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(value any) (float64, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return typed, true
|
||||||
|
case json.Number:
|
||||||
|
parsed, err := typed.Float64()
|
||||||
|
return parsed, err == nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||||
|
return parsed, err == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEventTime(value string) (time.Time, error) {
|
||||||
|
location := time.FixedZone("CST", 8*3600)
|
||||||
|
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05.000", value, location); err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
return time.ParseInLocation("2006-01-02 15:04:05", value, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDate(value string) time.Time {
|
||||||
|
parsed, err := time.Parse("2006-01-02", value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadJSON(path string, target any) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, target); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, value any) {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(value float64) float64 { return math.Round(value*1000) / 1000 }
|
||||||
|
func decimal(value float64) string { return strconv.FormatFloat(value, 'f', 3, 64) }
|
||||||
|
func integer(value int) string { return strconv.Itoa(value) }
|
||||||
@@ -477,7 +477,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined),
|
vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined),
|
||||||
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
||||||
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
|
historyLocations: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`, withSignal(undefined, signal)),
|
||||||
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||||
rawFramesQuery: (query: RawFrameQuery) => request<Page<RawFrameRow>>('/api/history/raw-frames/query', {
|
rawFramesQuery: (query: RawFrameQuery) => request<Page<RawFrameRow>>('/api/history/raw-frames/query', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ export interface AlertQuery { keyword?: string; severity?: string; status?: stri
|
|||||||
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
|
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
|
||||||
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
|
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
|
||||||
export interface AlertEvent {
|
export interface AlertEvent {
|
||||||
|
nativeAlarmFields?: Record<string, unknown>;
|
||||||
|
nativeAlarmNames?: string[]; nativeAlarmReservedBits?: number[];
|
||||||
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
|
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
|
||||||
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
|
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
|
||||||
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
|
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
|
||||||
@@ -901,6 +903,12 @@ export interface LatestTelemetryResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyMileageRow {
|
export interface DailyMileageRow {
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
region?: string;
|
||||||
|
locationTime?: string;
|
||||||
|
locationStatus?: string;
|
||||||
|
hydrogenPhysicalConsumptionKg?: number | null;
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -941,7 +949,7 @@ export interface HydrogenDailyEvidence {
|
|||||||
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
|
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
|
||||||
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
|
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
|
||||||
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
|
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
|
||||||
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
|
physicalConsumptionKgPer100Km?: number; consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
|
||||||
refuelCount: number; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: number; validSegmentCount: number; invalidSegmentCount: number;
|
refuelCount: number; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: number; validSegmentCount: number; invalidSegmentCount: number;
|
||||||
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
|
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const LEGACY_LOCAL_SQL_TIMESTAMP = /\.\d{6}Z$/;
|
|||||||
|
|
||||||
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
|
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
|
||||||
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||||
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
export const actionLabels: Record<string, string> = { repair: '修正告警信息', trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||||
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
|
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
|
||||||
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
|
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
|
||||||
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
|
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
|
||||||
|
|||||||
@@ -145,3 +145,25 @@ export function automationSource(rule: Pick<AlertRule, 'scopeProtocols'>) {
|
|||||||
if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0];
|
if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0];
|
||||||
return `${protocols.length} 类协议`;
|
return `${protocols.length} 类协议`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNativeAlarm(event: Pick<AlertEvent, 'ruleId'>) {
|
||||||
|
return event.ruleId === 'native-gb32960-alarm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeAlarmDetails(event: AlertEvent) {
|
||||||
|
const fields = event.nativeAlarmFields;
|
||||||
|
if (!fields) return [];
|
||||||
|
return [
|
||||||
|
['max_alarm_level', '最高报警等级'],
|
||||||
|
['general_alarm_flag', '通用报警标志'],
|
||||||
|
['battery_faults', '可充电储能装置故障码'],
|
||||||
|
['motor_faults', '驱动电机故障码'],
|
||||||
|
['engine_faults', '发动机故障码'],
|
||||||
|
['other_faults', '其他故障码'],
|
||||||
|
].map(([name, label]) => {
|
||||||
|
const value = fields[`gb32960.alarm.${name}`];
|
||||||
|
let text = value == null ? '未上报' : Array.isArray(value) ? (value.length ? value.join('、') : '无') : String(value);
|
||||||
|
if (name === 'max_alarm_level' && value != null) text = ({ '0': '0 · 无故障', '1': '1 · 一级故障', '2': '2 · 二级故障', '3': '3 · 三级故障', '254': '254 · 异常', '255': '255 · 无效' } as Record<string, string>)[String(value)] ?? text;
|
||||||
|
return { key: label, value: text };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
|||||||
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
|
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
|
||||||
],
|
],
|
||||||
mileageRows: [
|
mileageRows: [
|
||||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
|
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.126, hydrogenPhysicalConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
|
||||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
|
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
|
||||||
],
|
],
|
||||||
sources: [
|
sources: [
|
||||||
@@ -43,12 +43,12 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
|||||||
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
||||||
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
||||||
expect(hydrogenSheet.getRow(1).values).toEqual([
|
expect(hydrogenSheet.getRow(1).values).toEqual([
|
||||||
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
|
undefined, '日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
|
||||||
]);
|
]);
|
||||||
expect(hydrogenSheet.getRow(2).values).toEqual([
|
expect(hydrogenSheet.getRow(2).values).toEqual([
|
||||||
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960'
|
undefined, '2026-07-13', '粤A12345', '广东省', '广州市', '华南', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960', '2026-07-13 23:00:00', '已解析'
|
||||||
]);
|
]);
|
||||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 19 } });
|
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 24 } });
|
||||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,18 +93,20 @@ test('terminates the workbook worker when an active export is cancelled', async
|
|||||||
dateTo: '2026-07-14',
|
dateTo: '2026-07-14',
|
||||||
dates: ['2026-07-13', '2026-07-14'],
|
dates: ['2026-07-13', '2026-07-14'],
|
||||||
vehicles: [],
|
vehicles: [],
|
||||||
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
|
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
|
||||||
sources: []
|
sources: []
|
||||||
}, controller.signal);
|
}, controller.signal);
|
||||||
|
|
||||||
expect(postMessage).toHaveBeenCalledTimes(3);
|
expect(postMessage).toHaveBeenCalledTimes(3);
|
||||||
expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['start', 'rows', 'finish']);
|
expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['start', 'rows', 'finish']);
|
||||||
expect(postMessage.mock.calls[1][0].rows).toEqual([{
|
expect(postMessage.mock.calls[1][0].rows).toEqual([{
|
||||||
|
province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析',
|
||||||
vin: 'LTEST000000000001',
|
vin: 'LTEST000000000001',
|
||||||
date: '2026-07-13',
|
date: '2026-07-13',
|
||||||
dailyMileageKm: 88.7,
|
dailyMileageKm: 88.7,
|
||||||
pureHydrogenMileageKm: undefined,
|
pureHydrogenMileageKm: undefined,
|
||||||
hydrogenConsumptionKg: undefined,
|
hydrogenConsumptionKg: undefined,
|
||||||
|
hydrogenPhysicalConsumptionKg: undefined,
|
||||||
hydrogenConsumptionKgPer100Km: undefined,
|
hydrogenConsumptionKgPer100Km: undefined,
|
||||||
hydrogenSocBalancedKg: undefined,
|
hydrogenSocBalancedKg: undefined,
|
||||||
pureElectricMileageKm: undefined,
|
pureElectricMileageKm: undefined,
|
||||||
@@ -143,3 +145,10 @@ test('releases a streaming export before finish when the route aborts', async ()
|
|||||||
expect(terminate).toHaveBeenCalledTimes(1);
|
expect(terminate).toHaveBeenCalledTimes(1);
|
||||||
await expect(stream.finish([{ vin: 'VIN001', plate: '粤A00001' }])).rejects.toMatchObject({ name: 'AbortError' });
|
await expect(stream.finish([{ vin: 'VIN001', plate: '粤A00001' }])).rejects.toMatchObject({ name: 'AbortError' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('hydrogen export opens the hydrogen sheet with geography in the first five columns', async () => {
|
||||||
|
const workbook = await createMileageWorkbook({ metricView: 'hydrogen', dateFrom: '2026-09-01', dateTo: '2026-09-01', dates: ['2026-09-01'], vehicles: [], mileageRows: [], sources: [] });
|
||||||
|
expect(workbook.worksheets[0].name).toBe('氢能明细');
|
||||||
|
expect(Array.from(workbook.worksheets[0].getRow(1).values as unknown[]).slice(1, 6)).toEqual(['日期', '车牌', '当天所在省', '当天所在市', '大区']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type MileageExportSource = { protocol: string; label: string; mileageType
|
|||||||
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
|
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
|
||||||
|
|
||||||
export type MileageExportInput = {
|
export type MileageExportInput = {
|
||||||
|
metricView?: 'mileage' | 'hydrogen';
|
||||||
dateFrom: string;
|
dateFrom: string;
|
||||||
dateTo: string;
|
dateTo: string;
|
||||||
dates: string[];
|
dates: string[];
|
||||||
@@ -68,7 +69,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
|||||||
}
|
}
|
||||||
const buffer = event.data.buffer;
|
const buffer = event.data.buffer;
|
||||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
downloadBlob(blob, `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
|
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
|
||||||
resolveFinish?.();
|
resolveFinish?.();
|
||||||
dispose();
|
dispose();
|
||||||
};
|
};
|
||||||
@@ -84,11 +85,13 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
|||||||
post({
|
post({
|
||||||
type: 'rows',
|
type: 'rows',
|
||||||
rows: rows.map((row) => ({
|
rows: rows.map((row) => ({
|
||||||
|
province: row.province, city: row.city, region: row.region, locationTime: row.locationTime, locationStatus: row.locationStatus,
|
||||||
vin: row.vin,
|
vin: row.vin,
|
||||||
date: row.date,
|
date: row.date,
|
||||||
dailyMileageKm: row.dailyMileageKm,
|
dailyMileageKm: row.dailyMileageKm,
|
||||||
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
|
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
|
||||||
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
|
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
|
||||||
|
hydrogenPhysicalConsumptionKg: row.hydrogenPhysicalConsumptionKg,
|
||||||
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
|
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
|
||||||
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
|
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
|
||||||
pureElectricMileageKm: row.pureElectricMileageKm,
|
pureElectricMileageKm: row.pureElectricMileageKm,
|
||||||
@@ -126,6 +129,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
|||||||
|
|
||||||
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
|
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
|
||||||
const stream = createMileageExportStream({
|
const stream = createMileageExportStream({
|
||||||
|
metricView: input.metricView,
|
||||||
dateFrom: input.dateFrom,
|
dateFrom: input.dateFrom,
|
||||||
dateTo: input.dateTo,
|
dateTo: input.dateTo,
|
||||||
dates: input.dates,
|
dates: input.dates,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
workbook.modified = input.exportedAt ?? new Date();
|
workbook.modified = input.exportedAt ?? new Date();
|
||||||
workbook.calcProperties.fullCalcOnLoad = true;
|
workbook.calcProperties.fullCalcOnLoad = true;
|
||||||
|
|
||||||
|
// Create the selected view first so Excel/WPS opens the requested export.
|
||||||
|
const primaryHydrogenSheet = input.metricView === 'hydrogen' ? workbook.addWorksheet('氢能明细') : undefined;
|
||||||
const sheet = workbook.addWorksheet('里程查询', {
|
const sheet = workbook.addWorksheet('里程查询', {
|
||||||
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }],
|
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }],
|
||||||
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
|
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
|
||||||
@@ -133,14 +135,13 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
}
|
}
|
||||||
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
|
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
|
||||||
|
|
||||||
const hydrogenSheet = workbook.addWorksheet('氢能明细', {
|
const hydrogenSheet = primaryHydrogenSheet ?? workbook.addWorksheet('氢能明细');
|
||||||
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
|
hydrogenSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1, activeCell: 'C2', showGridLines: false }];
|
||||||
properties: { defaultRowHeight: 21 }
|
hydrogenSheet.properties.defaultRowHeight = 21;
|
||||||
});
|
|
||||||
const hydrogenHeaders = [
|
const hydrogenHeaders = [
|
||||||
'日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
|
'日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
|
||||||
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
|
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
|
||||||
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
|
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
|
||||||
];
|
];
|
||||||
hydrogenSheet.addRow(hydrogenHeaders);
|
hydrogenSheet.addRow(hydrogenHeaders);
|
||||||
const hydrogenHeader = hydrogenSheet.getRow(1);
|
const hydrogenHeader = hydrogenSheet.getRow(1);
|
||||||
@@ -158,6 +159,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
const row = hydrogenSheet.addRow([
|
const row = hydrogenSheet.addRow([
|
||||||
value.date,
|
value.date,
|
||||||
vehicle?.plate || value.plate || '未绑定',
|
vehicle?.plate || value.plate || '未绑定',
|
||||||
|
value.province || '未知', value.city || '未知', value.region || '未知',
|
||||||
value.vin,
|
value.vin,
|
||||||
vehicle?.brandName || '待维护',
|
vehicle?.brandName || '待维护',
|
||||||
vehicle?.modelName || '待维护',
|
vehicle?.modelName || '待维护',
|
||||||
@@ -173,20 +175,21 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
|||||||
value.refuelAmountKg ?? null,
|
value.refuelAmountKg ?? null,
|
||||||
value.hydrogenSocBalancedKg ?? null,
|
value.hydrogenSocBalancedKg ?? null,
|
||||||
value.hydrogenQualityReason || '',
|
value.hydrogenQualityReason || '',
|
||||||
value.hydrogenConsumptionKg ?? null,
|
value.hydrogenPhysicalConsumptionKg ?? null,
|
||||||
value.source ?? ''
|
value.source ?? '',
|
||||||
|
value.locationTime || '', value.locationStatus || '未查询'
|
||||||
]);
|
]);
|
||||||
row.height = 24;
|
row.height = 24;
|
||||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||||
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
|
cell.font = { name: columnNumber === 6 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 6 ? 9 : 10, color: { argb: 'FF34445A' } };
|
||||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 16 ? 'right' : 'left' };
|
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 9 && columnNumber <= 19 ? 'right' : 'left' };
|
||||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||||
if ([6, 8, 9, 10, 11, 13, 15, 16, 18].includes(columnNumber)) cell.numFmt = '#,##0.000';
|
if ([9, 11, 12, 13, 14, 16, 18, 19, 21].includes(columnNumber)) cell.numFmt = '#,##0.000';
|
||||||
if ([12, 14].includes(columnNumber)) cell.numFmt = '0';
|
if ([15, 17].includes(columnNumber)) cell.numFmt = '0';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
[13, 15, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16].forEach((width, index) => {
|
[13, 15, 18, 18, 12, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16, 30, 24].forEach((width, index) => {
|
||||||
hydrogenSheet.getColumn(index + 1).width = width;
|
hydrogenSheet.getColumn(index + 1).width = width;
|
||||||
});
|
});
|
||||||
if (energyRows.length) {
|
if (energyRows.length) {
|
||||||
|
|||||||
@@ -1359,3 +1359,42 @@ test('opens a recent automation run in a focused event inbox', async () => {
|
|||||||
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId=');
|
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId=');
|
||||||
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('runFromAutomationId=');
|
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('runFromAutomationId=');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test.each([false, true])('displays native GB32960 alarms without automation (mobile=%s)', async (mobile) => {
|
||||||
|
layout.mobile = mobile;
|
||||||
|
const event: AlertEvent = {
|
||||||
|
...alertEvent('native-event', 'NATIVEVIN', '粤A原生01'),
|
||||||
|
ruleId: 'native-gb32960-alarm', ruleName: '绝缘报警、驱动电机温度报警', ruleVersion: 0,
|
||||||
|
protocol: 'GB32960', metric: 'alarm_active',
|
||||||
|
nativeAlarmNames: ['绝缘报警', '驱动电机温度报警'], nativeAlarmReservedBits: [20, 21],
|
||||||
|
nativeAlarmFields: {
|
||||||
|
'gb32960.alarm.max_alarm_level': 2,
|
||||||
|
'gb32960.alarm.general_alarm_flag': '0x00000004',
|
||||||
|
'gb32960.alarm.battery_faults': ['0x00000001'],
|
||||||
|
'gb32960.alarm.motor_faults': [],
|
||||||
|
'gb32960.alarm.engine_faults': ['0x00000002'],
|
||||||
|
'gb32960.alarm.other_faults': [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||||
|
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
|
||||||
|
mocks.alertEventV2.mockResolvedValue(event);
|
||||||
|
mocks.alertRulesV2.mockResolvedValue([]);
|
||||||
|
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?eventId=native-event']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
const fields = await screen.findByRole('region', { name: '32960 原生告警字段' });
|
||||||
|
await waitFor(() => expect(fields).toHaveTextContent('2 · 二级故障'));
|
||||||
|
expect(fields).toHaveTextContent('0x00000004');
|
||||||
|
expect(fields).toHaveTextContent('可充电储能装置故障码0x00000001');
|
||||||
|
expect(fields).toHaveTextContent('驱动电机故障码无');
|
||||||
|
expect(fields).toHaveTextContent('发动机故障码0x00000002');
|
||||||
|
expect(fields).toHaveTextContent('其他故障码无');
|
||||||
|
expect(screen.queryByLabelText('事件证据')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('绝缘报警');
|
||||||
|
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('驱动电机温度报警');
|
||||||
|
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('bit20、bit21');
|
||||||
|
expect(screen.getByLabelText('事件执行轨迹')).toHaveTextContent('车辆原生告警入库');
|
||||||
|
expect(screen.getByLabelText('事件执行轨迹')).not.toHaveTextContent('匹配自动化');
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { AlertEvent, AlertNotification, AlertNotificationChannelCapability, AlertNotificationConfig, AlertNotificationRetryResult, AlertNotificationTarget, AlertQuery, AlertRule, AlertRuleInput, AlertRulePage, AlertRuleRevision, AlertStatus, AlertTriggerType, MetricDefinition, Page } from '../../api/types';
|
import type { AlertEvent, AlertNotification, AlertNotificationChannelCapability, AlertNotificationConfig, AlertNotificationRetryResult, AlertNotificationTarget, AlertQuery, AlertRule, AlertRuleInput, AlertRulePage, AlertRuleRevision, AlertStatus, AlertTriggerType, MetricDefinition, Page } from '../../api/types';
|
||||||
import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertDuration, formatAlertTime, operatorLabels, ruleCondition, thresholdText, triggerTypeLabels } from '../domain/alert';
|
import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertDuration, formatAlertTime, operatorLabels, ruleCondition, thresholdText, triggerTypeLabels } from '../domain/alert';
|
||||||
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, protocolEventSources, toVehicleEvent } from '../domain/event';
|
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, isNativeAlarm, nativeAlarmDetails, protocolEventSources, toVehicleEvent } from '../domain/event';
|
||||||
import { InlineError, PanelEmpty, PanelError, PanelLoading } from '../shared/AsyncState';
|
import { InlineError, PanelEmpty, PanelError, PanelLoading } from '../shared/AsyncState';
|
||||||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||||||
import { ProtocolTag } from '../shared/ProtocolTag';
|
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||||
@@ -341,7 +341,7 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac
|
|||||||
},
|
},
|
||||||
{ title: '协议', dataIndex: 'protocol', className: 'is-protocol', width: 98, render: (value: string) => <ProtocolTag className="v2-alert-protocol-tag" protocol={value} compact /> },
|
{ title: '协议', dataIndex: 'protocol', className: 'is-protocol', width: 98, render: (value: string) => <ProtocolTag className="v2-alert-protocol-tag" protocol={value} compact /> },
|
||||||
{ title: '发生时间', dataIndex: 'triggeredAt', className: 'is-triggered-at', width: 124, render: (value: string) => <AlertTime value={value} /> },
|
{ title: '发生时间', dataIndex: 'triggeredAt', className: 'is-triggered-at', width: 124, render: (value: string) => <AlertTime value={value} /> },
|
||||||
{ title: '匹配自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
|
{ title: '事件来源 / 自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string, event: AlertEvent) => <span className="v2-alert-event-rule" title={value}>{isNativeAlarm(event) ? '车辆原生告警' : value}</span> },
|
||||||
{ title: '执行状态', dataIndex: 'status', className: 'is-status', width: 92, render: (_: AlertEvent['status'], event: AlertEvent) => <EventExecutionTag event={event} /> },
|
{ title: '执行状态', dataIndex: 'status', className: 'is-status', width: 92, render: (_: AlertEvent['status'], event: AlertEvent) => <EventExecutionTag event={event} /> },
|
||||||
];
|
];
|
||||||
return compact
|
return compact
|
||||||
@@ -390,14 +390,22 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
|
|||||||
<div className="v2-alert-focus-facts" aria-label="事件上下文">
|
<div className="v2-alert-focus-facts" aria-label="事件上下文">
|
||||||
<span><small>协议来源</small><strong><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact unknownLabel="未知协议" /></strong></span>
|
<span><small>协议来源</small><strong><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact unknownLabel="未知协议" /></strong></span>
|
||||||
<span><small>发生时间</small><strong><AlertTime value={normalized.occurredAt} /></strong></span>
|
<span><small>发生时间</small><strong><AlertTime value={normalized.occurredAt} /></strong></span>
|
||||||
<span><small>匹配自动化</small><strong>v{event.ruleVersion}</strong></span>
|
<span><small>{isNativeAlarm(event) ? '事件来源' : '匹配自动化'}</small><strong>{isNativeAlarm(event) ? '车辆原生告警' : `v${event.ruleVersion}`}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="v2-alert-evidence" aria-label="事件证据">
|
{isNativeAlarm(event) ? <section aria-label="32960 原生告警字段">
|
||||||
|
<div className="v2-native-alarm-meaning" aria-label="具体告警信息">
|
||||||
|
<strong>具体告警信息</strong>
|
||||||
|
{event.nativeAlarmNames?.length ? <ul>{event.nativeAlarmNames.map((name) => <li key={name}>{name}</li>)}</ul> : <p>{event.ruleName}</p>}
|
||||||
|
{event.nativeAlarmReservedBits?.length ? <p>预留位 {event.nativeAlarmReservedBits.map((bit) => `bit${bit}`).join('、')} 置位,未计入具体告警。</p> : null}
|
||||||
|
</div>
|
||||||
|
<p>触发时车辆上报的原始字段</p>
|
||||||
|
{event.nativeAlarmFields ? <Descriptions className="v2-alert-descriptions" align="left" size="small" data={nativeAlarmDetails(event)} /> : <p>{loading ? '正在读取告警字段…' : '告警字段暂不可用'}</p>}
|
||||||
|
</section> : <div className="v2-alert-evidence" aria-label="事件证据">
|
||||||
<Card className="v2-alert-evidence-value is-trigger"><small>观测值</small><strong>{alertValue(event)}</strong></Card>
|
<Card className="v2-alert-evidence-value is-trigger"><small>观测值</small><strong>{alertValue(event)}</strong></Card>
|
||||||
<Card className="v2-alert-evidence-value"><small>匹配条件</small><strong>{thresholdText(event)}</strong></Card>
|
<Card className="v2-alert-evidence-value"><small>匹配条件</small><strong>{thresholdText(event)}</strong></Card>
|
||||||
<Card className="v2-alert-evidence-value is-delta"><small>变化幅度</small><strong>{alertDeltaText(event)}</strong></Card>
|
<Card className="v2-alert-evidence-value is-delta"><small>变化幅度</small><strong>{alertDeltaText(event)}</strong></Card>
|
||||||
</div>
|
</div>}
|
||||||
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small">自动化</Tag><span>{event.ruleName}</span></div>
|
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small">{isNativeAlarm(event) ? '车辆原生告警' : '自动化'}</Tag><span>{event.ruleName}</span></div>
|
||||||
</Card>
|
</Card>
|
||||||
{normalized.execution.requiresAttention ? <Card className="v2-alert-detail-card v2-alert-disposition-card" title={<span className="v2-alert-detail-title"><strong>处理说明</strong><Tag color={editable ? 'orange' : 'grey'} type="light" size="small">{editable ? '选填' : '只读'}</Tag></span>} headerLine aria-label="事件处置操作">
|
{normalized.execution.requiresAttention ? <Card className="v2-alert-detail-card v2-alert-disposition-card" title={<span className="v2-alert-detail-title"><strong>处理说明</strong><Tag color={editable ? 'orange' : 'grey'} type="light" size="small">{editable ? '选填' : '只读'}</Tag></span>} headerLine aria-label="事件处置操作">
|
||||||
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 4 }} placeholder="补充处理说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}</> : <p className="v2-role-notice">当前为只读角色,可查看完整证据与执行记录。</p>}
|
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 4 }} placeholder="补充处理说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}</> : <p className="v2-role-notice">当前为只读角色,可查看完整证据与执行记录。</p>}
|
||||||
@@ -406,8 +414,8 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
|
|||||||
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong>执行时间线</strong><Tag color="blue" type="light" size="small">{normalized.execution.label}</Tag></span>} headerLine>
|
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong>执行时间线</strong><Tag color="blue" type="light" size="small">{normalized.execution.label}</Tag></span>} headerLine>
|
||||||
<Timeline className="v2-alert-timeline v2-event-execution-trace" aria-label="事件执行轨迹">
|
<Timeline className="v2-alert-timeline v2-event-execution-trace" aria-label="事件执行轨迹">
|
||||||
<Timeline.Item time={<AlertTime value={event.receivedAt} />}><strong>事件已接收</strong><p>{event.protocol} 原始事件进入事件中心</p></Timeline.Item>
|
<Timeline.Item time={<AlertTime value={event.receivedAt} />}><strong>事件已接收</strong><p>{event.protocol} 原始事件进入事件中心</p></Timeline.Item>
|
||||||
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong>匹配自动化</strong><p>{event.ruleName} · v{event.ruleVersion}</p></Timeline.Item>
|
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '车辆原生告警入库' : '匹配自动化'}</strong><p>{event.ruleName}{isNativeAlarm(event) ? '' : ` · v${event.ruleVersion}`}</p></Timeline.Item>
|
||||||
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
|
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '记录告警事件' : normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
|
||||||
{event.actions?.filter((item) => item.action !== 'trigger').map((item) => <Timeline.Item key={item.id} type="default" time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}
|
{event.actions?.filter((item) => item.action !== 'trigger').map((item) => <Timeline.Item key={item.id} type="default" time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -420,7 +428,7 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
|
|||||||
...eventContract(event).map((item) => ({ key: item.key, value: <code>{item.value || '—'}</code> })),
|
...eventContract(event).map((item) => ({ key: item.key, value: <code>{item.value || '—'}</code> })),
|
||||||
{ key: 'event.id', value: <code>{event.id}</code> },
|
{ key: 'event.id', value: <code>{event.id}</code> },
|
||||||
{ key: 'source.event_id', value: <code>{event.sourceEventId || '—'}</code> },
|
{ key: 'source.event_id', value: <code>{event.sourceEventId || '—'}</code> },
|
||||||
{ key: 'automation', value: <code>{event.ruleId} / v{event.ruleVersion}</code> }
|
{ key: isNativeAlarm(event) ? 'source.kind' : 'automation', value: <code>{isNativeAlarm(event) ? 'native-gb32960' : `${event.ruleId} / v${event.ruleVersion}`}</code> }
|
||||||
]} />
|
]} />
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
@@ -567,7 +575,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, editable,
|
|||||||
aria-label={mobileLayout ? '事件列表,可上下滚动' : undefined}
|
aria-label={mobileLayout ? '事件列表,可上下滚动' : undefined}
|
||||||
>
|
>
|
||||||
{mobileLayout
|
{mobileLayout
|
||||||
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i>匹配条件</i>{thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
|
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i>{isNativeAlarm(event) ? '事件来源' : '匹配条件'}</i>{isNativeAlarm(event) ? '车辆原生告警' : thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||||
: <AlertEventTable rows={rows} selectedID={selectedID} compact={Boolean(selectedID)} onSelect={selectEvent} />}
|
: <AlertEventTable rows={rows} selectedID={selectedID} compact={Boolean(selectedID)} onSelect={selectEvent} />}
|
||||||
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
|
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
|
||||||
{!events.isFetching && !events.isError && !rows.length ? <PanelEmpty
|
{!events.isFetching && !events.isError && !rows.length ? <PanelEmpty
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user