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.
467 lines
16 KiB
Go
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
|
|
}
|