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 }