Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/customer_mileage.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

274 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"fmt"
"math"
"sort"
"strings"
)
// CustomerMileageRules apply to each vehicle-day before customer aggregation.
// Comparing customer totals would allow opposite vehicle discrepancies to cancel.
type CustomerMileageRules struct {
Version int64 `json:"version"`
LowMileageKm float64 `json:"lowMileageKm"`
TargetMileageKm float64 `json:"targetMileageKm"`
DeviationKm float64 `json:"deviationKm"`
DeviationPercent float64 `json:"deviationPercent"`
MinimumGPSKm float64 `json:"minimumGpsKm"`
IncludeToday bool `json:"includeToday"`
}
func DefaultCustomerMileageRules() CustomerMileageRules {
return CustomerMileageRules{Version: 1, LowMileageKm: 50, TargetMileageKm: 200, DeviationKm: 30, DeviationPercent: 20, MinimumGPSKm: 50}
}
func (r CustomerMileageRules) Validate() error {
if r.Version < 0 {
return fmt.Errorf("规则版本无效")
}
for _, value := range []float64{r.LowMileageKm, r.TargetMileageKm, r.DeviationKm, r.DeviationPercent, r.MinimumGPSKm} {
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return fmt.Errorf("预警阈值必须为非负有限数值")
}
}
if r.TargetMileageKm <= r.LowMileageKm || r.TargetMileageKm > 3000 {
return fmt.Errorf("达标里程必须大于低里程阈值且不超过 3000 km")
}
if r.DeviationKm <= 0 || r.DeviationKm > 3000 || r.DeviationPercent <= 0 || r.DeviationPercent > 100 || r.MinimumGPSKm > 3000 {
return fmt.Errorf("偏差阈值应为 0–3000 km、0–100%%(不含零),最低 GPS 里程不超过 3000 km")
}
return nil
}
type CustomerVehicleAssignment struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Customer string `json:"customer"`
Department string `json:"department"`
ResponsiblePerson string `json:"responsiblePerson"`
}
type CustomerVehicleDay struct {
Restricted bool `json:"restricted"`
HydrogenCoveragePercent *float64 `json:"hydrogenCoveragePercent"`
HydrogenObservedKm *float64 `json:"hydrogenObservedKm"`
HydrogenSource string `json:"hydrogenSource,omitempty"`
CustomerVehicleAssignment
Date string `json:"date"`
GPSKm *float64 `json:"gpsKm"`
HydrogenKm *float64 `json:"hydrogenKm"`
GPSStatus string `json:"gpsStatus"`
HydrogenStatus string `json:"hydrogenStatus"`
DifferenceKm *float64 `json:"differenceKm"`
DifferencePercent *float64 `json:"differencePercent"`
Alert string `json:"alert"`
Provisional bool `json:"provisional"`
QualityReason string `json:"qualityReason,omitempty"`
}
func validCustomerMileage(v *float64) bool {
return v != nil && !math.IsNaN(*v) && !math.IsInf(*v, 0) && *v >= 0
}
func customerMileageBand(value *float64, rules CustomerMileageRules) string {
if !validCustomerMileage(value) {
return "missing"
}
if *value < rules.LowMileageKm {
return "low"
}
if *value < rules.TargetMileageKm {
return "below-target"
}
return "on-target"
}
func evaluateCustomerVehicleDay(day CustomerVehicleDay, rules CustomerMileageRules) CustomerVehicleDay {
if day.Restricted {
day.GPSKm = nil
day.HydrogenKm = nil
day.GPSStatus = "restricted"
day.HydrogenStatus = "restricted"
day.DifferenceKm = nil
day.DifferencePercent = nil
day.Alert = "restricted"
return day
}
day.GPSStatus = customerMileageBand(day.GPSKm, rules)
day.HydrogenStatus = customerMileageBand(day.HydrogenKm, rules)
day.DifferenceKm, day.DifferencePercent = nil, nil
day.Alert = "normal"
if day.GPSStatus == "missing" || day.HydrogenStatus == "missing" {
day.Alert = "missing-data"
return day
}
difference := *day.GPSKm - *day.HydrogenKm
day.DifferenceKm = &difference
if *day.GPSKm > 0 {
percent := difference / *day.GPSKm * 100
day.DifferencePercent = &percent
}
if strings.TrimSpace(day.QualityReason) != "" {
day.Alert = "data-quality"
return day
}
if day.Provisional && !rules.IncludeToday {
day.Alert = "provisional"
return day
}
if difference < -rules.DeviationKm {
day.Alert = "reverse-deviation"
return day
}
if *day.GPSKm >= rules.MinimumGPSKm && difference >= rules.DeviationKm && day.DifferencePercent != nil && *day.DifferencePercent >= rules.DeviationPercent {
day.Alert = "suspected-charging"
}
return day
}
// CustomerMileageObservation holds independently selected GPS and hydrogen
// measurements. A missing source is nil, never an artificial zero.
type CustomerMileageObservation struct {
Restricted bool
HydrogenCoveragePercent *float64
HydrogenObservedKm *float64
HydrogenSource string
VIN string
Date string
GPSKm *float64
HydrogenKm *float64
QualityReason string
}
type CustomerMileageDay struct {
Date string `json:"date"`
VehicleCount int `json:"vehicleCount"`
GPSVehicleCount int `json:"gpsVehicleCount"`
HydrogenVehicleCount int `json:"hydrogenVehicleCount"`
GPSKm *float64 `json:"gpsKm"`
HydrogenKm *float64 `json:"hydrogenKm"`
SuspectedChargingCount int `json:"suspectedChargingCount"`
DataIssueCount int `json:"dataIssueCount"`
LowMileageCount int `json:"lowMileageCount"`
Provisional bool `json:"provisional"`
}
type CustomerMileageGroup struct {
Customer string `json:"customer"`
VehicleCount int `json:"vehicleCount"`
Departments []string `json:"departments"`
ResponsiblePeople []string `json:"responsiblePeople"`
Days []CustomerMileageDay `json:"days"`
Vehicles []CustomerVehicleDay `json:"vehicles"`
}
// aggregateCustomerMileage receives authorized assignments only. It excludes
// unassigned VINs and refuses conflicting current assignments instead of placing
// the same vehicle under multiple customers. Caller must scope measurements too.
func aggregateCustomerMileage(assignments []CustomerVehicleAssignment, observations []CustomerMileageObservation, dates []string, today string, rules CustomerMileageRules) ([]CustomerMileageGroup, error) {
if err := rules.Validate(); err != nil {
return nil, err
}
byVIN := map[string]CustomerVehicleAssignment{}
for _, a := range assignments {
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
a.Customer = strings.TrimSpace(a.Customer)
a.Department = strings.TrimSpace(a.Department)
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
if a.VIN == "" || a.Customer == "" {
continue
}
if prev, ok := byVIN[a.VIN]; ok && prev != a {
return nil, fmt.Errorf("车辆客户归属存在冲突,请刷新 OneOS 车辆数据")
}
byVIN[a.VIN] = a
}
type key struct{ vin, date string }
measured := map[key]CustomerMileageObservation{}
for _, o := range observations {
o.VIN = strings.ToUpper(strings.TrimSpace(o.VIN))
if _, ok := byVIN[o.VIN]; !ok {
continue
}
k := key{o.VIN, o.Date}
if _, ok := measured[k]; ok {
return nil, fmt.Errorf("同一车辆日期存在重复里程结果")
}
measured[k] = o
}
groups := map[string]*CustomerMileageGroup{}
vins := make([]string, 0, len(byVIN))
for vin := range byVIN {
vins = append(vins, vin)
}
sort.Strings(vins)
for _, vin := range vins {
a := byVIN[vin]
g := groups[a.Customer]
if g == nil {
g = &CustomerMileageGroup{Customer: a.Customer, Departments: []string{}, ResponsiblePeople: []string{}, Days: make([]CustomerMileageDay, len(dates)), Vehicles: []CustomerVehicleDay{}}
groups[a.Customer] = g
for i, date := range dates {
g.Days[i] = CustomerMileageDay{Date: date, Provisional: date == today}
}
}
g.VehicleCount++
if a.Department != "" {
g.Departments = appendUniqueCustomerValue(g.Departments, a.Department)
}
if a.ResponsiblePerson != "" {
g.ResponsiblePeople = appendUniqueCustomerValue(g.ResponsiblePeople, a.ResponsiblePerson)
}
for i, date := range dates {
o := measured[key{vin, date}]
d := evaluateCustomerVehicleDay(CustomerVehicleDay{CustomerVehicleAssignment: a, Date: date, GPSKm: o.GPSKm, HydrogenKm: o.HydrogenKm, Restricted: o.Restricted, HydrogenCoveragePercent: o.HydrogenCoveragePercent, HydrogenObservedKm: o.HydrogenObservedKm, HydrogenSource: o.HydrogenSource, QualityReason: o.QualityReason, Provisional: date == today}, rules)
g.Vehicles = append(g.Vehicles, d)
day := &g.Days[i]
if !d.Restricted {
day.VehicleCount++
}
if validCustomerMileage(d.GPSKm) {
addCustomerMileage(&day.GPSKm, *d.GPSKm)
day.GPSVehicleCount++
}
if validCustomerMileage(d.HydrogenKm) {
addCustomerMileage(&day.HydrogenKm, *d.HydrogenKm)
day.HydrogenVehicleCount++
}
if d.Alert == "suspected-charging" {
day.SuspectedChargingCount++
}
if d.Alert == "missing-data" || d.Alert == "data-quality" || d.Alert == "reverse-deviation" {
day.DataIssueCount++
}
if d.GPSStatus == "low" && (!d.Provisional || rules.IncludeToday) {
day.LowMileageCount++
}
}
}
result := make([]CustomerMileageGroup, 0, len(groups))
for _, g := range groups {
sort.Strings(g.Departments)
sort.Strings(g.ResponsiblePeople)
result = append(result, *g)
}
sort.Slice(result, func(i, j int) bool { return result[i].Customer < result[j].Customer })
return result, nil
}
func appendUniqueCustomerValue(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func addCustomerMileage(total **float64, value float64) {
if *total == nil {
*total = new(float64)
}
**total += value
}