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

This commit is contained in:
lingniu
2026-09-02 14:05:34 +08:00
parent 8759668d39
commit c6875fbb98
33 changed files with 5692 additions and 475 deletions
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -137,20 +138,65 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
return nil, nil
}
func (r *MySQLRepository) StationaryLocationPoints(ctx context.Context, vins []string, start, end time.Time, longitude, latitude, radiusMeters, maxSpeedKmh float64) ([]StationaryLocationPoint, error) {
if r.tdengine == nil || r.tdDatabase == "" {
return nil, errors.New("TDengine is not configured for stationary vehicle query")
}
if len(vins) == 0 {
return []StationaryLocationPoint{}, nil
}
// Use a small bounding box as TDengine's prefilter, then apply the exact
// 5-metre great-circle calculation in the service before returning a match.
latitudeDelta := radiusMeters / 111320.0
longitudeDelta := radiusMeters / math.Max(1, 111320.0*math.Cos(latitude*math.Pi/180))
quotedVINs := make([]string, 0, len(vins))
for _, vin := range vins {
quotedVINs = append(quotedVINs, "'"+strings.ReplaceAll(vin, "'", "''")+"'")
}
startLiteral := strings.ReplaceAll(start.Format(time.RFC3339), "'", "''")
endLiteral := strings.ReplaceAll(end.Format(time.RFC3339), "'", "''")
query := `SELECT CAST(ts AS BIGINT),vin,protocol,longitude,latitude,speed_kmh FROM ` + r.tdDatabase + `.vehicle_locations` +
` WHERE ts>='` + startLiteral + `' AND ts<='` + endLiteral + `'` +
` AND vin IN (` + strings.Join(quotedVINs, ",") + `)` +
` AND longitude BETWEEN ` + strconv.FormatFloat(longitude-longitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(longitude+longitudeDelta, 'f', 8, 64) +
` AND latitude BETWEEN ` + strconv.FormatFloat(latitude-latitudeDelta, 'f', 8, 64) + ` AND ` + strconv.FormatFloat(latitude+latitudeDelta, 'f', 8, 64) +
` AND speed_kmh BETWEEN 0 AND ` + strconv.FormatFloat(maxSpeedKmh, 'f', 3, 64) +
` ORDER BY vin,ts ASC,protocol ASC`
rows, err := r.tdengine.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
points := make([]StationaryLocationPoint, 0)
for rows.Next() {
var point StationaryLocationPoint
var timestampMS int64
if err := rows.Scan(&timestampMS, &point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh); err != nil {
return nil, err
}
point.ObservedAt = time.UnixMilli(timestampMS).In(time.FixedZone("Asia/Shanghai", 8*60*60))
points = append(points, point)
}
return points, rows.Err()
}
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
out := make(map[string]RealtimeVehiclePoint, len(vins))
if len(vins) == 0 {
return out, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
args := make([]any, 0, len(vins)+1)
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
args := make([]any, 0, len(vins)+2)
args = append(args, dayStart)
for _, vin := range vins {
args = append(args, vin)
}
args = append(args, now.Add(-10*time.Minute))
query := `
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
COALESCE(l.speed_kmh,0),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
FROM vehicle_realtime_location l
WHERE BINARY l.vin IN (` + placeholders + `)
ORDER BY l.vin,
@@ -165,9 +211,14 @@ ORDER BY l.vin,
onlineThreshold := now.Add(-time.Minute)
for rows.Next() {
var point RealtimeVehiclePoint
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil {
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 {
return nil, err
}
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
value := round3(soc.Float64)
point.SOCPercent = &value
}
point.Online = !point.ObservedAt.Before(onlineThreshold)
if selected, exists := out[point.VIN]; !exists {
out[point.VIN] = point
@@ -183,35 +234,57 @@ ORDER BY l.vin,
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
where := []string{
"s.longitude BETWEEN -180 AND 180",
"s.latitude BETWEEN -90 AND 90",
"NOT (s.longitude=0 AND s.latitude=0)",
"n.del_flag='0'",
"n.longitude BETWEEN -180 AND 180",
"n.latitude BETWEEN -90 AND 90",
"NOT (n.longitude=0 AND n.latitude=0)",
}
args := make([]any, 0, 3)
args := make([]any, 0, 4)
if request.Province != "" {
where = append(where, "s.province=?")
args = append(args, request.Province)
where = append(where, "(n.province=? OR province_region.NAME=?)")
args = append(args, request.Province, request.Province)
}
if request.City != "" {
where = append(where, "s.city=?")
args = append(args, request.City)
}
if request.CooperateOnly != nil {
if *request.CooperateOnly {
where = append(where, "s.inner_site_id IS NOT NULL")
} else {
where = append(where, "s.inner_site_id IS NULL")
}
where = append(where, "(n.city=? OR city_region.NAME=?)")
args = append(args, request.City, request.City)
}
rows, err := r.db.QueryContext(ctx, `
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''),
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''),
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''),
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END
FROM ln_asset_management.tab_outside_hydrogen_site s
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0'
SELECT CAST(n.id AS CHAR),COALESCE(n.site_name,''),COALESCE(n.site_short_name,''),COALESCE(n.site_address,''),
n.longitude,n.latitude,
COALESCE(NULLIF(province_region.NAME,''),n.province,''),
COALESCE(NULLIF(city_region.NAME,''),n.city,''),COALESCE(s.district,''),
CASE WHEN s.inner_site_id IS NOT NULL OR (
n.cooperation_start_date IS NOT NULL
AND n.cooperation_start_date<=CURDATE()
AND (n.cooperation_end_date IS NULL OR n.cooperation_end_date>=CURDATE())
) THEN 1 ELSE 0 END,
COALESCE(n.contact_person,''),COALESCE(NULLIF(n.mobile_phone,''),n.fixed_phone,''),COALESCE(n.cost_price,0),
COALESCE(l.monthly_hydrogen_kg,0),COALESCE(l.total_hydrogen_kg,n.fill_weight,0)
FROM ln_asset_management.new_hydrogen_site n
LEFT JOIN ln_asset_management.common_district province_region
ON province_region.CODE COLLATE utf8mb4_general_ci=n.province COLLATE utf8mb4_general_ci
LEFT JOIN ln_asset_management.common_district city_region
ON city_region.CODE COLLATE utf8mb4_general_ci=n.city COLLATE utf8mb4_general_ci
LEFT JOIN ln_asset_management.tab_outside_hydrogen_site s ON s.id=(
SELECT MIN(matched.id)
FROM ln_asset_management.tab_outside_hydrogen_site matched
WHERE matched.fixed_station_name=n.site_name COLLATE utf8mb4_general_ci
OR matched.station_name=n.site_name COLLATE utf8mb4_general_ci
OR matched.fixed_station_name=n.site_short_name COLLATE utf8mb4_general_ci
OR matched.station_name=n.site_short_name COLLATE utf8mb4_general_ci
)
LEFT JOIN (
SELECT station_id,
SUM(amount_kg) AS total_hydrogen_kg,
SUM(CASE WHEN refuel_date >= DATE_FORMAT(CURDATE(),'%Y-%m-01')
AND refuel_date < DATE_ADD(DATE_FORMAT(CURDATE(),'%Y-%m-01'),INTERVAL 1 MONTH)
THEN amount_kg ELSE 0 END) AS monthly_hydrogen_kg
FROM ln_asset_management.hydrogen_fuel_ledger
WHERE del_flag='0'
GROUP BY station_id
) l ON l.station_id=s.inner_site_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY s.province,s.city,s.fixed_station_name,s.id
ORDER BY province_region.NAME,city_region.NAME,n.site_name,n.id
LIMIT 2000`, args...)
if err != nil {
return nil, err
@@ -220,9 +293,15 @@ LIMIT 2000`, args...)
stations := make([]HydrogenStation, 0, 512)
for rows.Next() {
var station HydrogenStation
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil {
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative, &station.ContactPerson, &station.ContactPhone, &station.UnitPrice, &station.MonthlyHydrogenKg, &station.TotalHydrogenKg); err != nil {
return nil, err
}
if isExcelSinopecStation(station.Name, station.ShortName) {
station.Cooperative = true
}
if request.CooperateOnly != nil && station.Cooperative != *request.CooperateOnly {
continue
}
stations = append(stations, station)
}
return stations, rows.Err()
@@ -264,6 +343,48 @@ func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date
return out, nil
}
// MileageRollbacks returns source days that were rejected because the odometer
// moved backwards. They must not silently fall back to an earlier total mileage
// and be exposed as normal data.
func (r *MySQLRepository) MileageRollbacks(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]bool, error) {
out := make(map[string]bool)
if len(vins) == 0 {
return out, nil
}
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
query := `
SELECT DISTINCT vin,DATE_FORMAT(stat_date,'%Y-%m-%d')
FROM vehicle_daily_mileage_source
WHERE stat_date BETWEEN ? AND ?
AND vin IN (` + vinPlaceholders + `)
AND quality_reason='TOTAL_MILEAGE_ROLLBACK'`
args := make([]any, 0, len(vins)+2+len(protocols))
args = append(args, startDate, endDate)
for _, vin := range vins {
args = append(args, vin)
}
if len(protocols) > 0 {
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
query += "\n AND protocol IN (" + protocolPlaceholders + ")"
for _, protocol := range protocols {
args = append(args, protocol)
}
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var vin, date string
if err := rows.Scan(&vin, &date); err != nil {
return nil, err
}
out[dailyMileageKey(vin, date)] = true
}
return out, rows.Err()
}
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 {
return map[string]DailyMileage{}, nil
@@ -275,7 +396,7 @@ SELECT
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol,
m.daily_mileage_km,
m.latest_total_mileage_km,
COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected
@@ -289,8 +410,8 @@ SELECT
FROM vehicle_daily_mileage m
WHERE m.stat_date BETWEEN ? AND ?
AND m.vin IN (` + placeholders + `)
AND m.latest_total_mileage_km IS NOT NULL
AND m.latest_total_mileage_km>=0
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km) IS NOT NULL
AND COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km)>=0
AND m.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+2+len(protocols)*2)
args = append(args, startDate, endDate)
@@ -345,7 +466,7 @@ SELECT
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol,
m.daily_mileage_km,
m.latest_total_mileage_km,
COALESCE(m.day_end_total_mileage_km,m.latest_total_mileage_km),
COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected
@@ -362,8 +483,8 @@ JOIN (
FROM vehicle_daily_mileage prior
WHERE prior.stat_date<?
AND prior.vin IN (` + vinPlaceholders + `)
AND prior.latest_total_mileage_km IS NOT NULL
AND prior.latest_total_mileage_km>=0
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km) IS NOT NULL
AND COALESCE(prior.day_end_total_mileage_km,prior.latest_total_mileage_km)>=0
AND prior.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+1+len(protocols)*2)
args = append(args, beforeDate)