Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/operation_compliance.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

467 lines
16 KiB
Go

package platform
import (
"context"
"encoding/json"
"math/big"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const operationComplianceMenu = "operation-compliance"
var operationMileageDecimal = regexp.MustCompile(`^[0-9]{1,15}(\.[0-9]{1,3})?$`)
type OperationMileageTotal struct {
VIN string
Meters *int64
ValidDays int
InvalidDays int
}
type OperationComplianceStore interface {
OperationVehicleRoster(context.Context, url.Values) ([]CustomerVehicleAssignment, error)
OperationAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error)
OperationMileageTotals(context.Context, url.Values) ([]OperationMileageTotal, error)
OperationMileageDays(context.Context, url.Values) ([]OperationMileageDay, error)
}
type OperationComplianceRow struct {
CustomerVehicleAssignment
MileageKm *float64 `json:"mileageKm"`
Status string `json:"status"`
ValidDays int `json:"validDays"`
ExpectedDays int `json:"expectedDays"`
AuthorizedDays int `json:"authorizedDays"`
InvalidDays int `json:"invalidDays"`
MissingDays int `json:"missingDays"`
Incomplete bool `json:"incomplete"`
Reasons []string `json:"reasons"`
}
type OperationComplianceSummary struct {
Total int `json:"total"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Insufficient int `json:"insufficient"`
PassRate *float64 `json:"passRate"`
}
type OperationComplianceOptions struct {
Customers []string `json:"customers"`
Departments []string `json:"departments"`
ResponsiblePeople []string `json:"responsiblePeople"`
}
type OperationComplianceResponse struct {
Items []OperationComplianceRow `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Summary OperationComplianceSummary `json:"summary"`
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
TargetKm float64 `json:"targetKm"`
MileageSource string `json:"mileageSource"`
AssignmentSource string `json:"assignmentSource"`
AssignmentObservedAt string `json:"assignmentObservedAt"`
AssignmentStale bool `json:"assignmentStale"`
AssignmentMissingCount int `json:"assignmentMissingCount"`
}
func operationMileageSource(q url.Values) (string, error) {
if len(q["mileageSource"]) > 1 {
return "", clientError{Code: "INVALID_MILEAGE_SOURCE", Message: "请选择一个里程数据源"}
}
source := q.Get("mileageSource")
if source == "" {
source = "gps"
}
if source != "gps" && source != "odometer" && source != "hydrogen" {
return "", clientError{Code: "INVALID_MILEAGE_SOURCE", Message: "里程数据源仅支持GPS、仪表或纯氢里程"}
}
return source, nil
}
func operationSourceLabel(source string) string {
if source == "odometer" {
return "仪表"
}
if source == "hydrogen" {
return "纯氢"
}
return "GPS"
}
func operationDates(q url.Values) ([]string, error) {
datesQuery := url.Values{"dateFrom": {q.Get("dateFrom")}, "dateTo": {q.Get("dateTo")}}
if datesQuery.Get("dateFrom") == "" && datesQuery.Get("dateTo") == "" {
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600))
datesQuery.Set("dateFrom", now.AddDate(0, 0, -30).Format("2006-01-02"))
datesQuery.Set("dateTo", now.AddDate(0, 0, -1).Format("2006-01-02"))
}
return customerMileageDates(datesQuery, time.Now())
}
func operationComplianceAccess(ctx context.Context) (Principal, error) {
p, ok := PrincipalFromContext(ctx)
if !ok || p.AuthorizationStatus == "pending" || p.AuthorizationStatus == "loading" || !p.CanMenu(operationComplianceMenu) {
return p, clientError{Code: "MENU_PERMISSION_DENIED", Message: "当前账号无权查看车辆运营达标统计"}
}
return p, nil
}
func parseOperationTarget(q url.Values) (int64, error) {
values, exists := q["targetKm"]
if !exists {
return 3000000, nil
}
if len(values) != 1 || !operationMileageDecimal.MatchString(values[0]) {
return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程必须为正数,最多保留三位小数"}
}
r, ok := new(big.Rat).SetString(values[0])
if !ok {
return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程无效"}
}
r.Mul(r, big.NewRat(1000, 1))
if !r.IsInt() || !r.Num().IsInt64() || r.Sign() <= 0 {
return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程必须大于零且在有效数值范围内"}
}
return r.Num().Int64(), nil
}
func operationSelections(q url.Values, key string) (map[string]bool, error) {
if len(q[key]) > 200 {
return nil, clientError{Code: "INVALID_FILTER", Message: "单项筛选最多选择200项"}
}
out := map[string]bool{}
for _, value := range q[key] {
value = strings.TrimSpace(value)
if len([]rune(value)) > 512 {
return nil, clientError{Code: "INVALID_FILTER", Message: "筛选项过长"}
}
if value != "" {
out[value] = true
}
}
return out, nil
}
// The roster is authoritative: measurements and ownership are left joins, so
// unreported or unassigned authorized vehicles remain in the report.
func (s *Service) operationRoster(ctx context.Context) ([]CustomerVehicleAssignment, OperationComplianceResponse, error) {
meta := OperationComplianceResponse{Items: []OperationComplianceRow{}}
p, err := operationComplianceAccess(ctx)
if err != nil {
return nil, meta, err
}
store, ok := s.store.(OperationComplianceStore)
if !ok {
return nil, meta, clientError{Code: "OPERATION_COMPLIANCE_UNAVAILABLE", Message: "当前数据源尚未启用车辆运营达标统计"}
}
snapshot, _ := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot)
var roster, assignments []CustomerVehicleAssignment
observed := snapshot.ObservedAt
if snapshot.Live {
assignments, err = normalizeCustomerAssignments(snapshot.Rows)
roster = assignments
meta.AssignmentSource = "oneos-live"
} else {
tenant := p.TenantRef
if snapshot.Tenant != "" {
tenant = snapshot.Tenant
}
assignments, observed, err = store.OperationAssignments(ctx, tenant)
if err != nil {
return nil, meta, err
}
assignments, err = normalizeCustomerAssignments(assignments)
if err != nil {
return nil, meta, err
}
query, scopeErr := applyPrincipalVehicleScope(ctx, url.Values{})
if scopeErr != nil {
return nil, meta, scopeErr
}
roster, err = store.OperationVehicleRoster(ctx, query)
meta.AssignmentSource = "oneos-synced"
}
if err != nil {
return nil, meta, err
}
meta.AssignmentStale = observed.IsZero() || time.Since(observed) > 24*time.Hour
if !observed.IsZero() {
meta.AssignmentObservedAt = observed.UTC().Format(time.RFC3339)
}
byVIN := map[string]CustomerVehicleAssignment{}
for _, row := range assignments {
byVIN[row.VIN] = row
}
result := []CustomerVehicleAssignment{}
seen := map[string]bool{}
for _, row := range roster {
row.VIN = strings.ToUpper(strings.TrimSpace(row.VIN))
if !isLikelyVIN(row.VIN) || !p.CanVIN(row.VIN) || seen[row.VIN] {
continue
}
seen[row.VIN] = true
if assignment, exists := byVIN[row.VIN]; exists {
if assignment.Plate == "" {
assignment.Plate = row.Plate
}
row = assignment
} else {
meta.AssignmentMissingCount++
}
result = append(result, row)
}
sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN })
return result, meta, nil
}
func (s *Service) OperationComplianceOptions(ctx context.Context) (OperationComplianceOptions, error) {
roster, _, err := s.operationRoster(ctx)
out := OperationComplianceOptions{Customers: []string{}, Departments: []string{}, ResponsiblePeople: []string{}}
if err != nil {
return out, err
}
customers, departments, people := map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, r := range roster {
if r.Customer != "" {
customers[r.Customer] = true
}
if r.Department != "" {
departments[r.Department] = true
}
if r.ResponsiblePerson != "" {
people[r.ResponsiblePerson] = true
}
}
for value := range customers {
out.Customers = append(out.Customers, value)
}
for value := range departments {
out.Departments = append(out.Departments, value)
}
for value := range people {
out.ResponsiblePeople = append(out.ResponsiblePeople, value)
}
sort.Strings(out.Customers)
sort.Strings(out.Departments)
sort.Strings(out.ResponsiblePeople)
return out, nil
}
func operationAuthorizedBounds(p Principal, vin, from, to string) (string, string, int) {
zone := time.FixedZone("Asia/Shanghai", 8*3600)
start, _ := time.ParseInLocation("2006-01-02", from, zone)
end, _ := time.ParseInLocation("2006-01-02", to, zone)
if p.HasHistoryTimeScope() {
grant, ok := p.VehicleGrant(vin)
if !ok || grant.ValidFrom.IsZero() {
return "", "", 0
}
g := grant.ValidFrom.In(zone)
first := time.Date(g.Year(), g.Month(), g.Day(), 0, 0, 0, 0, zone)
if !first.Equal(g) {
first = first.AddDate(0, 0, 1)
}
if first.After(start) {
start = first
}
if grant.ValidTo != nil {
g = grant.ValidTo.In(zone)
last := time.Date(g.Year(), g.Month(), g.Day(), 0, 0, 0, 0, zone).AddDate(0, 0, -1)
if last.Before(end) {
end = last
}
}
}
if start.After(end) {
return "", "", 0
}
return start.Format("2006-01-02"), end.Format("2006-01-02"), int(end.Sub(start)/(24*time.Hour)) + 1
}
func (s *Service) OperationCompliance(ctx context.Context, q url.Values) (OperationComplianceResponse, error) {
var empty OperationComplianceResponse
source, err := operationMileageSource(q)
if err != nil {
return empty, err
}
target, err := parseOperationTarget(q)
if err != nil {
return empty, err
}
dates, err := operationDates(q)
if err != nil {
return empty, err
}
filters := make([]map[string]bool, 3)
for i, key := range []string{"customer", "department", "responsiblePerson"} {
filters[i], err = operationSelections(q, key)
if err != nil {
return empty, err
}
}
status := q.Get("status")
if status != "" && status != "passed" && status != "failed" && status != "insufficient" {
return empty, clientError{Code: "INVALID_STATUS", Message: "达标状态无效"}
}
order := q.Get("sort")
if order == "" {
order = "mileage-desc"
}
if order != "mileage-desc" && order != "mileage-asc" {
return empty, clientError{Code: "INVALID_SORT", Message: "里程排序无效"}
}
limit, offset := 30, 0
if q.Has("limit") {
limit, err = strconv.Atoi(q.Get("limit"))
if err != nil || limit < 1 || limit > 100 {
return empty, clientError{Code: "INVALID_PAGINATION", Message: "每页数量应为1至100"}
}
}
if q.Has("offset") {
offset, err = strconv.Atoi(q.Get("offset"))
if err != nil || offset < 0 {
return empty, clientError{Code: "INVALID_PAGINATION", Message: "分页位置无效"}
}
}
roster, response, err := s.operationRoster(ctx)
if err != nil {
return response, err
}
p, _ := PrincipalFromContext(ctx)
selected := []CustomerVehicleAssignment{}
query := url.Values{"dateFrom": {dates[0]}, "dateTo": {dates[len(dates)-1]}, "mileageSource": {source}}
starts, ends, authorized := map[string]string{}, map[string]string{}, map[string]int{}
vins := []string{}
for _, row := range roster {
if len(filters[0]) > 0 && !filters[0][row.Customer] || len(filters[1]) > 0 && !filters[1][row.Department] || len(filters[2]) > 0 && !filters[2][row.ResponsiblePerson] {
continue
}
selected = append(selected, row)
from, to, days := operationAuthorizedBounds(p, row.VIN, dates[0], dates[len(dates)-1])
authorized[row.VIN] = days
if days > 0 {
vins = append(vins, row.VIN)
starts[row.VIN] = from
ends[row.VIN] = to
}
}
if len(selected)*len(dates) > 100000 {
return response, clientError{Code: "OPERATION_RANGE_TOO_LARGE", Message: "本次查询超过100,000个车辆日,请缩短日期或筛选车辆归属"}
}
totals := map[string]OperationMileageTotal{}
if len(vins) > 0 {
query.Set("vins", strings.Join(vins, ","))
encoded, _ := json.Marshal(starts)
query.Set("scopeVehicleFrom", string(encoded))
encoded, _ = json.Marshal(ends)
query.Set("operationVehicleTo", string(encoded))
rows, queryErr := s.store.(OperationComplianceStore).OperationMileageTotals(ctx, query)
if queryErr != nil {
return response, queryErr
}
for _, row := range rows {
if _, exists := totals[row.VIN]; exists {
return response, clientError{Code: "OPERATION_DUPLICATE_TOTAL", Message: "同一车辆存在重复区间汇总"}
}
totals[row.VIN] = row
}
}
response.DateFrom, response.DateTo, response.TargetKm = dates[0], dates[len(dates)-1], float64(target)/1000
response.MileageSource = source
response.Limit, response.Offset = limit, offset
rows := []OperationComplianceRow{}
for _, vehicle := range selected {
total := totals[vehicle.VIN]
row, rowErr := evaluateOperationRow(vehicle, total, authorized[vehicle.VIN], len(dates), target, source)
if rowErr != nil {
return response, rowErr
}
if status != "" && row.Status != status {
continue
}
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
if a.MileageKm == nil || b.MileageKm == nil {
if (a.MileageKm == nil) != (b.MileageKm == nil) {
return a.MileageKm != nil
}
return a.VIN < b.VIN
}
// Ordering uses the exact integer totals too; displayed decimals are presentation only.
am, bm := *totals[a.VIN].Meters, *totals[b.VIN].Meters
if am == bm {
return a.VIN < b.VIN
}
if order == "mileage-asc" {
return am < bm
}
return am > bm
})
response.Total = len(rows)
response.Summary.Total = len(rows)
for _, row := range rows {
switch row.Status {
case "passed":
response.Summary.Passed++
case "failed":
response.Summary.Failed++
default:
response.Summary.Insufficient++
}
}
if denominator := response.Summary.Passed + response.Summary.Failed; denominator > 0 {
rate := float64(response.Summary.Passed) / float64(denominator) * 100
response.Summary.PassRate = &rate
}
if offset < len(rows) {
end := offset + limit
if end > len(rows) || end < offset {
end = len(rows)
}
response.Items = rows[offset:end]
}
return response, nil
}
func evaluateOperationRow(vehicle CustomerVehicleAssignment, total OperationMileageTotal, authorized, expected int, target int64, source string) (OperationComplianceRow, error) {
row := OperationComplianceRow{CustomerVehicleAssignment: vehicle, ExpectedDays: expected, AuthorizedDays: authorized, ValidDays: total.ValidDays, InvalidDays: total.InvalidDays, Reasons: []string{}}
row.MissingDays = authorized - total.ValidDays - total.InvalidDays
if row.MissingDays < 0 || row.ValidDays < 0 || row.InvalidDays < 0 {
return row, clientError{Code: "OPERATION_INVALID_COVERAGE", Message: "里程覆盖计数异常"}
}
row.Incomplete = row.ValidDays != expected
if authorized < expected {
row.Reasons = append(row.Reasons, "部分日期不在完整自然日历史授权范围内")
}
if row.InvalidDays > 0 {
row.Reasons = append(row.Reasons, "存在异常或冲突日里程,已排除")
}
if row.MissingDays > 0 {
row.Reasons = append(row.Reasons, "部分授权日期缺少"+operationSourceLabel(source)+"日里程")
}
row.Status = "insufficient"
if total.Meters != nil && row.ValidDays > 0 {
value := float64(*total.Meters) / 1000
row.MileageKm = &value
if *total.Meters >= target {
row.Status = "passed"
} else if !row.Incomplete {
row.Status = "failed"
}
} else if authorized > 0 {
row.Reasons = append(row.Reasons, "暂无有效"+operationSourceLabel(source)+"日里程")
}
return row, nil
}