feat(operations): add automated reconciliation center

This commit is contained in:
lingniu
2026-07-16 19:14:53 +08:00
parent bbab018d55
commit d67f42b4f4
25 changed files with 1697 additions and 39 deletions

View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
if err := run(); err != nil {
log.Printf("reconciliation evaluation failed: %v", err)
os.Exit(1)
}
}
func run() error {
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
timeout := 5 * time.Minute
if value := strings.TrimSpace(os.Getenv("RECONCILIATION_TIMEOUT_SEC")); value != "" {
parsed, err := time.ParseDuration(value + "s")
if err != nil || parsed < 10*time.Second || parsed > 30*time.Minute {
return fmt.Errorf("RECONCILIATION_TIMEOUT_SEC must be between 10 and 1800 seconds")
}
timeout = parsed
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := platform.OpenSQL(ctx, "mysql", dsn)
if err != nil {
return fmt.Errorf("connect mysql: %w", err)
}
defer db.Close()
service := platform.NewService(platform.NewProductionStore(db, nil, ""))
started := time.Now()
result, err := service.EvaluateReconciliation(ctx)
if err != nil {
return err
}
log.Printf(
"reconciliation evaluation completed run=%s detected=%d new=%d active=%d recovered=%d rules=%v duration=%s",
result.RunID, result.Detected, result.New, result.Active, result.Recovered, result.RuleCounts, time.Since(started),
)
return nil
}

View File

@@ -0,0 +1,21 @@
package main
import (
"strings"
"testing"
)
func TestRunRequiresMySQLDSN(t *testing.T) {
t.Setenv("MYSQL_DSN", "")
if err := run(); err == nil || !strings.Contains(err.Error(), "MYSQL_DSN is required") {
t.Fatalf("run() error=%v", err)
}
}
func TestRunRejectsUnsafeTimeout(t *testing.T) {
t.Setenv("MYSQL_DSN", "ignored")
t.Setenv("RECONCILIATION_TIMEOUT_SEC", "1")
if err := run(); err == nil || !strings.Contains(err.Error(), "between 10 and 1800") {
t.Fatalf("run() error=%v", err)
}
}

View File

@@ -232,6 +232,12 @@ func requiredMenu(r *http.Request) string {
}
func requiredRole(r *http.Request) string {
if strings.HasPrefix(r.URL.Path, "/api/v2/reconciliation/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodPost {
return "operator"
}
return "admin"
}
if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return "operator"

View File

@@ -106,6 +106,14 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorSourcePolicy.Code != http.StatusForbidden {
t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code)
}
viewerReconciliation := authRequest(t, cfg, http.MethodGet, "/api/v2/reconciliation/summary", viewerToken)
if viewerReconciliation.Code != http.StatusForbidden {
t.Fatalf("viewer reconciliation summary should be forbidden, status=%d", viewerReconciliation.Code)
}
operatorReconciliation := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/actions", operatorToken)
if operatorReconciliation.Code != http.StatusNoContent {
t.Fatalf("operator reconciliation action status=%d body=%s", operatorReconciliation.Code, operatorReconciliation.Body.String())
}
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))

View File

@@ -63,6 +63,10 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/source-evidence", h.handleVehicleSourceEvidence)
h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic)
h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy)
h.mux.HandleFunc("GET /api/v2/reconciliation/summary", h.handleReconciliationSummary)
h.mux.HandleFunc("POST /api/v2/reconciliation/issues", h.handleReconciliationIssues)
h.mux.HandleFunc("GET /api/v2/reconciliation/issues/{id}", h.handleReconciliationIssue)
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/actions", h.handleReconciliationAction)
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
@@ -89,6 +93,34 @@ func (h *Handler) routes() {
h.mux.HandleFunc("POST /api/v2/alerts/notifications/read", h.handleAlertNotificationsRead)
}
func (h *Handler) handleReconciliationSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.ReconciliationSummary(r.Context(), parsePositive(r.URL.Query().Get("days"), 30))
h.write(w, r, data, err)
}
func (h *Handler) handleReconciliationIssues(w http.ResponseWriter, r *http.Request) {
var query ReconciliationQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.ReconciliationIssues(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleReconciliationIssue(w http.ResponseWriter, r *http.Request) {
data, err := h.service.ReconciliationIssue(r.Context(), r.PathValue("id"))
h.write(w, r, data, err)
}
func (h *Handler) handleReconciliationAction(w http.ResponseWriter, r *http.Request) {
var request ReconciliationActionRequest
if !decodeJSONBody(w, r, &request) {
return
}
data, err := h.service.UpdateReconciliationIssue(r.Context(), r.PathValue("id"), request)
h.write(w, r, data, err)
}
func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *http.Request) {
var query AccessUnresolvedIdentityQuery
if !decodeJSONBody(w, r, &query) {

View File

@@ -29,6 +29,36 @@ func TestHandlerDashboardSummary(t *testing.T) {
}
}
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
summary := httptest.NewRecorder()
handler.ServeHTTP(summary, httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/summary?days=30", nil))
if summary.Code != http.StatusOK || !strings.Contains(summary.Body.String(), `"active":1`) {
t.Fatalf("summary status=%d body=%s", summary.Code, summary.Body.String())
}
list := httptest.NewRecorder()
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","limit":20}`)))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "POSITION_DRIFT") {
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
}
detail := httptest.NewRecorder()
handler.ServeHTTP(detail, httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/issues/reconciliation-demo-position", nil))
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "规则首次发现差异") {
t.Fatalf("detail status=%d body=%s", detail.Code, detail.Body.String())
}
review := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/actions", strings.NewReader(`{"version":1,"status":"confirmed_source_a","note":"已核对原始报文,来源 A 可信"}`))
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维甲", Role: "operator", UserType: "operator"}))
handler.ServeHTTP(review, request)
if review.Code != http.StatusOK || !strings.Contains(review.Body.String(), `"status":"confirmed_source_a"`) || !strings.Contains(review.Body.String(), `"actor":"运维甲"`) {
t.Fatalf("review status=%d body=%s", review.Code, review.Body.String())
}
}
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {

View File

@@ -11,20 +11,22 @@ import (
)
type MockStore struct {
vehicles []VehicleRow
locations []RealtimeLocationRow
accessMu sync.RWMutex
accessThresholds AccessThresholdConfig
sourcePolicyMu sync.RWMutex
sourcePolicies map[string]VehicleSourcePolicyConfig
profileMu sync.RWMutex
profiles map[string]VehicleProfile
alertMu sync.RWMutex
alertRules []AlertRule
alertEvents []AlertEvent
alertNotifications []AlertNotification
nextAlertActionID int64
nextNotificationID int64
vehicles []VehicleRow
locations []RealtimeLocationRow
accessMu sync.RWMutex
accessThresholds AccessThresholdConfig
sourcePolicyMu sync.RWMutex
sourcePolicies map[string]VehicleSourcePolicyConfig
profileMu sync.RWMutex
profiles map[string]VehicleProfile
alertMu sync.RWMutex
alertRules []AlertRule
alertEvents []AlertEvent
alertNotifications []AlertNotification
nextAlertActionID int64
nextNotificationID int64
reconciliationMu sync.RWMutex
reconciliationIssues []ReconciliationIssue
}
func NewMockStore() *MockStore {
@@ -49,11 +51,169 @@ func NewMockStore() *MockStore {
},
}
store.seedAlertCenter()
store.seedReconciliationCenter()
return store
}
func int64Pointer(value int64) *int64 { return &value }
func (m *MockStore) seedReconciliationCenter() {
m.reconciliationIssues = []ReconciliationIssue{
{
ID: "reconciliation-demo-position", RuleCode: "POSITION_DRIFT", Category: "location", Severity: "major", Status: "pending",
VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", ProtocolA: "GB32960", ProtocolB: "JT808",
Title: "多来源实时位置漂移", Summary: "两个有效来源位置相差 1,286 米,系统不自动判定哪一来源正确",
Evidence: map[string]any{"distanceM": 1286, "sourceA": map[string]any{"protocol": "GB32960"}, "sourceB": map[string]any{"protocol": "JT808"}},
FirstSeenAt: "2026-07-16 08:00:00", LastSeenAt: "2026-07-16 10:00:00", OccurrenceCount: 3, Version: 1,
Actions: []ReconciliationAction{{ID: 1, Action: "detect", FromStatus: "", ToStatus: "pending", Actor: "reconciliation-evaluator", Note: "规则首次发现差异", CreatedAt: "2026-07-16 08:00:00"}},
},
{
ID: "reconciliation-demo-source", RuleCode: "SOURCE_MISSING", Category: "source", Severity: "minor", Status: "recovered",
VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Title: "主车辆暂无真实数据来源",
Summary: "车辆已建档,但当前没有真实来源证据", Evidence: map[string]any{"vin": "LB9A32A24P0LS1230"},
FirstSeenAt: "2026-07-14 08:00:00", LastSeenAt: "2026-07-15 08:00:00", OccurrenceCount: 2,
RecoveredAt: "2026-07-16 02:15:00", Version: 2,
},
}
}
func cloneReconciliationIssue(item ReconciliationIssue) ReconciliationIssue {
item.Actions = append([]ReconciliationAction(nil), item.Actions...)
if item.Evidence == nil {
item.Evidence = map[string]any{}
} else {
next := make(map[string]any, len(item.Evidence))
for key, value := range item.Evidence {
next[key] = value
}
item.Evidence = next
}
return item
}
func (m *MockStore) ReconciliationSummary(_ context.Context, _ int) (ReconciliationSummary, error) {
m.reconciliationMu.RLock()
defer m.reconciliationMu.RUnlock()
result := ReconciliationSummary{
ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{},
Trend: []ReconciliationTrendPoint{{Date: "2026-07-16", Detected: 1, New: 1, Active: 1, Recovered: 1}},
LastRunAt: "2026-07-16 02:15:00", AsOf: time.Now().Format(time.RFC3339),
}
rules := map[string]int{}
severities := map[string]int{}
for _, item := range m.reconciliationIssues {
switch item.Status {
case "pending":
result.Active++
result.Pending++
case "confirmed_source_a", "confirmed_source_b":
result.Active++
result.Confirmed++
case "recovered":
result.Recovered++
}
if item.Status == "pending" || strings.HasPrefix(item.Status, "confirmed_source_") {
rules[item.RuleCode]++
severities[item.Severity]++
}
}
for name, count := range rules {
result.ByRule = append(result.ByRule, ReconciliationBucket{Name: name, Count: count})
}
for name, count := range severities {
result.BySeverity = append(result.BySeverity, ReconciliationBucket{Name: name, Count: count})
}
sort.Slice(result.ByRule, func(i, j int) bool { return result.ByRule[i].Count > result.ByRule[j].Count })
sort.Slice(result.BySeverity, func(i, j int) bool { return result.BySeverity[i].Count > result.BySeverity[j].Count })
return result, nil
}
func (m *MockStore) ReconciliationIssues(_ context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
m.reconciliationMu.RLock()
defer m.reconciliationMu.RUnlock()
items := make([]ReconciliationIssue, 0, len(m.reconciliationIssues))
for _, item := range m.reconciliationIssues {
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary), strings.ToLower(query.Keyword)) {
continue
}
if query.RuleCode != "" && query.RuleCode != "all" && item.RuleCode != query.RuleCode {
continue
}
if query.Category != "" && query.Category != "all" && item.Category != query.Category {
continue
}
if query.Severity != "" && query.Severity != "all" && item.Severity != query.Severity {
continue
}
if query.Status == "active" && item.Status != "pending" && !strings.HasPrefix(item.Status, "confirmed_source_") {
continue
}
if query.Status != "" && query.Status != "all" && query.Status != "active" && item.Status != query.Status {
continue
}
cloned := cloneReconciliationIssue(item)
cloned.Actions = nil
items = append(items, cloned)
}
total := len(items)
if query.Offset >= total {
items = []ReconciliationIssue{}
} else {
end := min(total, query.Offset+query.Limit)
items = items[query.Offset:end]
}
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func (m *MockStore) ReconciliationIssue(_ context.Context, id string) (ReconciliationIssue, error) {
m.reconciliationMu.RLock()
defer m.reconciliationMu.RUnlock()
for _, item := range m.reconciliationIssues {
if item.ID == id {
return cloneReconciliationIssue(item), nil
}
}
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
m.reconciliationMu.Lock()
defer m.reconciliationMu.Unlock()
for index := range m.reconciliationIssues {
item := &m.reconciliationIssues[index]
if item.ID != id {
continue
}
if item.Version != request.Version {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
}
from := item.Status
item.Status = request.Status
item.ResolutionNote = request.Note
item.ResolvedBy = request.Actor
item.Version++
if request.Status == "fixed" || request.Status == "no_action" {
item.RecoveredAt = time.Now().Format("2006-01-02 15:04:05")
} else {
item.RecoveredAt = ""
}
item.Actions = append(item.Actions, ReconciliationAction{
ID: int64(len(item.Actions) + 1), Action: "review", FromStatus: from, ToStatus: request.Status,
Actor: request.Actor, Note: request.Note, CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
})
return cloneReconciliationIssue(*item), nil
}
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
func (m *MockStore) EvaluateReconciliation(_ context.Context) (ReconciliationEvaluationResult, error) {
summary, _ := m.ReconciliationSummary(context.Background(), 30)
return ReconciliationEvaluationResult{
RunID: "reconciliation-run-demo", Detected: summary.Active, Active: summary.Active,
RuleCounts: map[string]int{"POSITION_DRIFT": 1}, AsOf: time.Now().Format(time.RFC3339),
}, nil
}
func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string) ([]vehicleLocationSourceHistory, error) {
if vin != "LB9A32A24R0LS1426" {
return []vehicleLocationSourceHistory{}, nil

View File

@@ -736,6 +736,92 @@ type AlertNotificationReadRequest struct {
Actor string `json:"actor"`
}
type ReconciliationQuery struct {
Keyword string `json:"keyword"`
RuleCode string `json:"ruleCode"`
Category string `json:"category"`
Severity string `json:"severity"`
Status string `json:"status"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type ReconciliationIssue struct {
ID string `json:"id"`
RuleCode string `json:"ruleCode"`
Category string `json:"category"`
Severity string `json:"severity"`
Status string `json:"status"`
VIN string `json:"vin"`
Plate string `json:"plate"`
ProtocolA string `json:"protocolA"`
ProtocolB string `json:"protocolB"`
Title string `json:"title"`
Summary string `json:"summary"`
Evidence map[string]any `json:"evidence"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
OccurrenceCount int64 `json:"occurrenceCount"`
RecoveredAt string `json:"recoveredAt"`
ResolutionNote string `json:"resolutionNote"`
ResolvedBy string `json:"resolvedBy"`
Version int `json:"version"`
Actions []ReconciliationAction `json:"actions,omitempty"`
}
type ReconciliationAction struct {
ID int64 `json:"id"`
Action string `json:"action"`
FromStatus string `json:"fromStatus"`
ToStatus string `json:"toStatus"`
Actor string `json:"actor"`
Note string `json:"note"`
CreatedAt string `json:"createdAt"`
}
type ReconciliationActionRequest struct {
Version int `json:"version"`
Status string `json:"status"`
Note string `json:"note"`
Actor string `json:"actor"`
}
type ReconciliationBucket struct {
Name string `json:"name"`
Count int `json:"count"`
}
type ReconciliationTrendPoint struct {
Date string `json:"date"`
Detected int `json:"detected"`
New int `json:"new"`
Active int `json:"active"`
Recovered int `json:"recovered"`
}
type ReconciliationSummary struct {
Active int `json:"active"`
Pending int `json:"pending"`
Confirmed int `json:"confirmed"`
Recovered int `json:"recovered"`
OverSLA int `json:"overSla"`
ByRule []ReconciliationBucket `json:"byRule"`
BySeverity []ReconciliationBucket `json:"bySeverity"`
Trend []ReconciliationTrendPoint `json:"trend"`
LastRunAt string `json:"lastRunAt"`
AsOf string `json:"asOf"`
}
type ReconciliationEvaluationResult struct {
RunID string `json:"runId"`
Detected int `json:"detected"`
New int `json:"new"`
Active int `json:"active"`
Recovered int `json:"recovered"`
RuleCounts map[string]int `json:"ruleCounts"`
AsOf string `json:"asOf"`
}
type AlertEvaluationResult struct {
RulesEvaluated int `json:"rulesEvaluated"`
VehiclesScanned int `json:"vehiclesScanned"`

View File

@@ -12,19 +12,21 @@ import (
)
type ProductionStore struct {
db *sql.DB
tdengine *sql.DB
tdDatabase string
redisOnline redisOnlineKeyCounter
capacityCheck capacityChecker
alertStreamGroup string
alertStreamMode string
accessSchemaOnce sync.Once
accessSchemaErr error
alertSchemaOnce sync.Once
alertSchemaErr error
profileSchemaOnce sync.Once
profileSchemaErr error
db *sql.DB
tdengine *sql.DB
tdDatabase string
redisOnline redisOnlineKeyCounter
capacityCheck capacityChecker
alertStreamGroup string
alertStreamMode string
accessSchemaOnce sync.Once
accessSchemaErr error
alertSchemaOnce sync.Once
alertSchemaErr error
profileSchemaOnce sync.Once
profileSchemaErr error
reconciliationSchemaOnce sync.Once
reconciliationSchemaErr error
}
type redisOnlineKeyCounter interface {

View File

@@ -0,0 +1,78 @@
package platform
import (
"context"
"fmt"
"strings"
)
func (s *Service) reconciliationStore() (ReconciliationStore, error) {
store, ok := s.store.(ReconciliationStore)
if !ok {
return nil, fmt.Errorf("store does not provide reconciliation center")
}
return store, nil
}
func (s *Service) ReconciliationSummary(ctx context.Context, days int) (ReconciliationSummary, error) {
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationSummary{}, err
}
if days <= 0 || days > 90 {
days = 30
}
return store.ReconciliationSummary(ctx, days)
}
func (s *Service) ReconciliationIssues(ctx context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
store, err := s.reconciliationStore()
if err != nil {
return Page[ReconciliationIssue]{}, err
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.RuleCode = strings.TrimSpace(query.RuleCode)
query.Category = strings.TrimSpace(query.Category)
query.Severity = strings.TrimSpace(query.Severity)
query.Status = strings.TrimSpace(query.Status)
if query.Limit <= 0 || query.Limit > 200 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
return store.ReconciliationIssues(ctx, query)
}
func (s *Service) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
}
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationIssue{}, err
}
return store.ReconciliationIssue(ctx, id)
}
func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
}
request.Actor = ActorFromContext(ctx)
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationIssue{}, err
}
return store.UpdateReconciliationIssue(ctx, id, request)
}
func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationEvaluationResult{}, err
}
return store.EvaluateReconciliation(ctx)
}

View File

@@ -0,0 +1,619 @@
package platform
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
const reconciliationFindingLimit = 50000
type ReconciliationStore interface {
ReconciliationSummary(context.Context, int) (ReconciliationSummary, error)
ReconciliationIssues(context.Context, ReconciliationQuery) (Page[ReconciliationIssue], error)
ReconciliationIssue(context.Context, string) (ReconciliationIssue, error)
UpdateReconciliationIssue(context.Context, string, ReconciliationActionRequest) (ReconciliationIssue, error)
EvaluateReconciliation(context.Context) (ReconciliationEvaluationResult, error)
}
type reconciliationFinding struct {
SubjectKey string
RuleCode string
Category string
Severity string
VIN string
Plate string
ProtocolA string
ProtocolB string
Title string
Summary string
Evidence map[string]any
}
type reconciliationExisting struct {
ID string
Status string
Version int
}
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
s.reconciliationSchemaOnce.Do(func() {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name IN ('vehicle_reconciliation_run','vehicle_reconciliation_issue','vehicle_reconciliation_action')`).Scan(&count); err != nil {
s.reconciliationSchemaErr = err
return
}
if count != 3 {
s.reconciliationSchemaErr = fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql")
}
})
return s.reconciliationSchemaErr
}
func (s *ProductionStore) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationEvaluationResult{}, err
}
startedAt := time.Now()
runID, err := newAlertID("reconciliation-run")
if err != nil {
return ReconciliationEvaluationResult{}, err
}
findings, err := s.collectReconciliationFindings(ctx)
if err != nil {
return ReconciliationEvaluationResult{}, err
}
if len(findings) > reconciliationFindingLimit {
return ReconciliationEvaluationResult{}, fmt.Errorf("reconciliation findings exceed limit: %d", len(findings))
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return ReconciliationEvaluationResult{}, err
}
defer tx.Rollback()
existing, err := loadReconciliationExisting(ctx, tx)
if err != nil {
return ReconciliationEvaluationResult{}, err
}
now := time.Now()
seen := make(map[string]bool, len(findings))
result := ReconciliationEvaluationResult{RunID: runID, RuleCounts: map[string]int{}, AsOf: now.Format(time.RFC3339)}
for _, finding := range findings {
fingerprint := reconciliationFingerprint(finding)
if seen[fingerprint] {
continue
}
seen[fingerprint] = true
result.Detected++
result.RuleCounts[finding.RuleCode]++
evidence, err := json.Marshal(finding.Evidence)
if err != nil {
return ReconciliationEvaluationResult{}, err
}
current, exists := existing[fingerprint]
if !exists {
id, err := newAlertID("reconciliation")
if err != nil {
return ReconciliationEvaluationResult{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_issue(
id,fingerprint,rule_code,category,severity,status,vin,plate,protocol_a,protocol_b,title,summary,evidence_json,
first_seen_at,last_seen_at,occurrence_count,version
) VALUES(?,?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,1,1)`,
id, fingerprint, finding.RuleCode, finding.Category, finding.Severity, finding.VIN, finding.Plate,
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, now); err != nil {
return ReconciliationEvaluationResult{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'detect','','pending','reconciliation-evaluator','规则首次发现差异')`, id); err != nil {
return ReconciliationEvaluationResult{}, err
}
result.New++
continue
}
nextStatus := current.Status
if current.Status == "recovered" || current.Status == "fixed" {
nextStatus = "pending"
}
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
rule_code=?,category=?,severity=?,status=?,vin=?,plate=?,protocol_a=?,protocol_b=?,title=?,summary=?,evidence_json=?,
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,version=version+1
WHERE id=?`, finding.RuleCode, finding.Category, finding.Severity, nextStatus, finding.VIN, finding.Plate,
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, current.ID); err != nil {
return ReconciliationEvaluationResult{}, err
}
if nextStatus != current.Status {
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'reopen',?,?,'reconciliation-evaluator','已恢复或已修复的差异再次出现')`, current.ID, current.Status, nextStatus); err != nil {
return ReconciliationEvaluationResult{}, err
}
}
}
for fingerprint, current := range existing {
if seen[fingerprint] || !reconciliationAutoRecoverable(current.Status) {
continue
}
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
status='recovered',recovered_at=?,version=version+1 WHERE id=?`, now, current.ID); err != nil {
return ReconciliationEvaluationResult{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'recover',?,'recovered','reconciliation-evaluator','本轮检测未再发现该差异')`, current.ID, current.Status); err != nil {
return ReconciliationEvaluationResult{}, err
}
result.Recovered++
}
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
return ReconciliationEvaluationResult{}, err
}
ruleCountsJSON, _ := json.Marshal(result.RuleCounts)
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_run(
run_id,status,detected_count,new_count,active_count,recovered_count,rule_counts_json,started_at,finished_at
) VALUES(?,'completed',?,?,?,?,?,?,?)`, runID, result.Detected, result.New, result.Active, result.Recovered, string(ruleCountsJSON), startedAt, now); err != nil {
return ReconciliationEvaluationResult{}, err
}
if err := tx.Commit(); err != nil {
return ReconciliationEvaluationResult{}, err
}
return result, nil
}
func reconciliationAutoRecoverable(status string) bool {
return status == "pending" || status == "confirmed_source_a" || status == "confirmed_source_b"
}
func reconciliationFingerprint(finding reconciliationFinding) string {
key := strings.Join([]string{
strings.TrimSpace(finding.RuleCode), strings.TrimSpace(finding.SubjectKey),
strings.ToUpper(strings.TrimSpace(finding.VIN)), strings.TrimSpace(finding.ProtocolA), strings.TrimSpace(finding.ProtocolB),
}, "|")
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]reconciliationExisting, error) {
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version FROM vehicle_reconciliation_issue FOR UPDATE`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]reconciliationExisting{}
for rows.Next() {
var fingerprint string
var item reconciliationExisting
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version); err != nil {
return nil, err
}
out[fingerprint] = item
}
return out, rows.Err()
}
func (s *ProductionStore) collectReconciliationFindings(ctx context.Context) ([]reconciliationFinding, error) {
queries := []struct {
name string
text string
}{
{name: "identity", text: reconciliationIdentitySQL},
{name: "coverage", text: reconciliationCoverageSQL},
{name: "position", text: reconciliationPositionSQL},
{name: "mileage", text: reconciliationMileageSQL},
{name: "fleet_count", text: reconciliationFleetCountSQL},
{name: "business_scope", text: reconciliationBusinessScopeSQL},
}
out := make([]reconciliationFinding, 0, 1024)
for _, query := range queries {
rows, err := s.db.QueryContext(ctx, query.text)
if err != nil {
return nil, fmt.Errorf("reconciliation query %s: %w", query.name, err)
}
for rows.Next() {
var item reconciliationFinding
var evidenceJSON string
if err := rows.Scan(&item.SubjectKey, &item.RuleCode, &item.Category, &item.Severity, &item.VIN, &item.Plate,
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidenceJSON); err != nil {
rows.Close()
return nil, fmt.Errorf("scan reconciliation query %s: %w", query.name, err)
}
item.Evidence = map[string]any{}
if err := json.Unmarshal([]byte(evidenceJSON), &item.Evidence); err != nil {
rows.Close()
return nil, fmt.Errorf("decode reconciliation evidence for %s: %w", item.RuleCode, err)
}
out = append(out, item)
if len(out) > reconciliationFindingLimit {
rows.Close()
return nil, fmt.Errorf("reconciliation findings exceed limit")
}
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close reconciliation query %s: %w", query.name, err)
}
}
return out, nil
}
const reconciliationIdentitySQL = `
SELECT CONCAT('plate:',duplicate.plate),'DUPLICATE_PLATE','identity','major','',duplicate.plate,'','','同一车牌关联多个 VIN',
CONCAT('车牌 ',duplicate.plate,' 当前关联 ',duplicate.vin_count,' 个 VIN需核对权威身份绑定'),
JSON_OBJECT('plate',duplicate.plate,'vins',duplicate.vins,'vinCount',duplicate.vin_count)
FROM (
SELECT TRIM(plate) plate,GROUP_CONCAT(DISTINCT vin ORDER BY vin) vins,COUNT(DISTINCT vin) vin_count
FROM vehicle_identity_binding
WHERE plate IS NOT NULL AND TRIM(plate)<>''
GROUP BY TRIM(plate) HAVING COUNT(DISTINCT vin)>1
) duplicate
UNION ALL
SELECT CONCAT('phone:',duplicate.identifier),'DUPLICATE_PHONE','identity','major','',duplicate.plate,'JT808','','同一终端关联多个 VIN',
CONCAT('JT808 终端标识当前关联 ',duplicate.vin_count,' 个 VIN需核对设备换绑或误挂'),
JSON_OBJECT('identifierHash',SHA2(duplicate.identifier,256),'vins',duplicate.vins,'vinCount',duplicate.vin_count)
FROM (
SELECT TRIM(phone) identifier,MAX(COALESCE(plate,'')) plate,
GROUP_CONCAT(DISTINCT vin ORDER BY vin) vins,COUNT(DISTINCT vin) vin_count
FROM vehicle_identity_binding
WHERE phone IS NOT NULL AND TRIM(phone)<>''
GROUP BY TRIM(phone) HAVING COUNT(DISTINCT vin)>1
) duplicate`
const reconciliationCoverageSQL = `
SELECT CONCAT('unbound:',s.vin),'UNBOUND_SOURCE','source','major',s.vin,MAX(COALESCE(s.plate,'')),
GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol),'','实时来源未绑定主车辆',
CONCAT('来源快照存在 VIN但 vehicle_identity_binding 无对应主车辆:',s.vin),
JSON_OBJECT('vin',s.vin,'protocols',GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol),'latestSeen',DATE_FORMAT(MAX(s.updated_at),'%Y-%m-%d %H:%i:%s'))
FROM vehicle_realtime_snapshot s
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY s.vin
WHERE s.vin IS NOT NULL AND s.vin<>'' AND b.vin IS NULL
GROUP BY s.vin
UNION ALL
SELECT CONCAT('missing:',b.vin),'SOURCE_MISSING','source','minor',b.vin,COALESCE(b.plate,''),
'','','主车辆暂无真实数据来源','车辆已建档,但当前没有 GB32960、JT808 或 YUTONG_MQTT 来源证据',
JSON_OBJECT('vin',b.vin,'plate',COALESCE(b.plate,''),'bindingSource','vehicle_identity_binding')
FROM vehicle_identity_binding b
LEFT JOIN vehicle_realtime_snapshot s ON BINARY s.vin=BINARY b.vin
WHERE b.vin IS NOT NULL AND b.vin<>''
GROUP BY b.vin,b.plate HAVING COUNT(s.protocol)=0`
const reconciliationPositionSQL = `
SELECT CONCAT('position:',a.vin,':',LEAST(a.protocol,b.protocol),':',GREATEST(a.protocol,b.protocol),':',SHA2(CONCAT(LEAST(a.source_key,b.source_key),'|',GREATEST(a.source_key,b.source_key)),256)),
'POSITION_DRIFT','location',
CASE WHEN ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude))>=5000 THEN 'critical' ELSE 'major' END,
a.vin,COALESCE(binding.plate,''),a.protocol,b.protocol,'多来源实时位置漂移',
CONCAT('两个有效来源位置相差 ',ROUND(ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude))), ' 米,系统不自动判定哪一来源正确'),
JSON_OBJECT(
'sourceA',JSON_OBJECT('protocol',a.protocol,'sourceHash',SHA2(a.source_key,256),'longitude',a.longitude,'latitude',a.latitude,'receivedAt',DATE_FORMAT(a.received_at,'%Y-%m-%d %H:%i:%s')),
'sourceB',JSON_OBJECT('protocol',b.protocol,'sourceHash',SHA2(b.source_key,256),'longitude',b.longitude,'latitude',b.latitude,'receivedAt',DATE_FORMAT(b.received_at,'%Y-%m-%d %H:%i:%s')),
'distanceM',ROUND(ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude)))
)
FROM vehicle_realtime_location_source a
JOIN vehicle_realtime_location_source b ON BINARY b.vin=BINARY a.vin
AND CONCAT(b.protocol,'|',b.source_key)>CONCAT(a.protocol,'|',a.source_key)
LEFT JOIN vehicle_identity_binding binding ON BINARY binding.vin=BINARY a.vin
WHERE a.received_at>=DATE_SUB(NOW(),INTERVAL 5 MINUTE)
AND b.received_at>=DATE_SUB(NOW(),INTERVAL 5 MINUTE)
AND a.longitude BETWEEN -180 AND 180 AND b.longitude BETWEEN -180 AND 180
AND a.latitude BETWEEN -90 AND 90 AND b.latitude BETWEEN -90 AND 90
AND NOT (a.longitude=0 AND a.latitude=0) AND NOT (b.longitude=0 AND b.latitude=0)
AND ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude))>=1000`
const reconciliationMileageSQL = `
SELECT CONCAT('mileage:',m.vin,':',m.protocol,':',DATE_FORMAT(m.stat_date,'%Y-%m-%d')),
CASE WHEN m.daily_mileage_km<0 OR m.latest_total_mileage_km<m.daily_mileage_km THEN 'MILEAGE_REVERSE' ELSE 'MILEAGE_JUMP' END,
'mileage',
CASE WHEN m.daily_mileage_km<0 OR m.latest_total_mileage_km<m.daily_mileage_km THEN 'critical' ELSE 'major' END,
m.vin,COALESCE(b.plate,''),m.protocol,'',
CASE WHEN m.daily_mileage_km<0 OR m.latest_total_mileage_km<m.daily_mileage_km THEN '里程倒退或起始值无效' ELSE '单日里程异常跳变' END,
CONCAT(DATE_FORMAT(m.stat_date,'%Y-%m-%d'),' ',m.protocol,' 日里程 ',ROUND(m.daily_mileage_km,3),' km需核对原始总里程与单位'),
JSON_OBJECT('date',DATE_FORMAT(m.stat_date,'%Y-%m-%d'),'protocol',m.protocol,'dailyMileageKm',m.daily_mileage_km,
'latestTotalMileageKm',m.latest_total_mileage_km,'derivedStartMileageKm',m.latest_total_mileage_km-m.daily_mileage_km)
FROM vehicle_daily_mileage m
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY m.vin
WHERE m.stat_date>=DATE_SUB(CURDATE(),INTERVAL 7 DAY)
AND (m.daily_mileage_km<0 OR m.daily_mileage_km>2000 OR m.latest_total_mileage_km<m.daily_mileage_km)
UNION ALL
SELECT CONCAT('mileage-spread:',m.vin,':',DATE_FORMAT(m.stat_date,'%Y-%m-%d')),
'MILEAGE_SOURCE_DIVERGENCE','mileage','major',m.vin,COALESCE(MAX(b.plate),''),
MIN(m.protocol),MAX(m.protocol),'多来源日里程差异偏大',
CONCAT(DATE_FORMAT(m.stat_date,'%Y-%m-%d'),' 多来源日里程最大差 ',ROUND(MAX(m.daily_mileage_km)-MIN(m.daily_mileage_km),3),' km'),
JSON_OBJECT('date',DATE_FORMAT(m.stat_date,'%Y-%m-%d'),'minimumKm',MIN(m.daily_mileage_km),'maximumKm',MAX(m.daily_mileage_km),
'differenceKm',MAX(m.daily_mileage_km)-MIN(m.daily_mileage_km),'protocols',GROUP_CONCAT(DISTINCT m.protocol ORDER BY m.protocol))
FROM vehicle_daily_mileage m
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY m.vin
WHERE m.stat_date>=DATE_SUB(CURDATE(),INTERVAL 7 DAY)
GROUP BY m.vin,m.stat_date
HAVING COUNT(DISTINCT m.protocol)>1 AND MAX(m.daily_mileage_km)-MIN(m.daily_mileage_km)>20`
const reconciliationFleetCountSQL = `
SELECT 'fleet-count','FLEET_COUNT_MISMATCH','fleet','major','','','','','主车辆与来源车辆总数不一致',
CONCAT('主车辆 ',x.bound_count,' 辆,来源并集 ',x.source_union_count,' 辆,来源未绑定 ',x.unbound_count,' 辆'),
JSON_OBJECT('boundVehicles',x.bound_count,'sourceUnionVehicles',x.source_union_count,'unboundSourceVehicles',x.unbound_count)
FROM (
SELECT
(SELECT COUNT(DISTINCT vin) FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'') bound_count,
(SELECT COUNT(*) FROM (
SELECT CAST(vin AS BINARY) vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>''
UNION SELECT CAST(vin AS BINARY) vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>''
) u) source_union_count,
(SELECT COUNT(DISTINCT s.vin) FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY s.vin
WHERE s.vin IS NOT NULL AND s.vin<>'' AND b.vin IS NULL) unbound_count
) x
WHERE x.bound_count<>x.source_union_count OR x.unbound_count>0`
const reconciliationBusinessScopeSQL = `
SELECT
CONVERT(CONCAT('business-unbound:',s.customer_id,':',s.vin) USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'BUSINESS_SCOPE_UNBOUND','business','critical',
CONVERT(s.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci,
CONVERT(s.plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'ONEOS','','OneOS 业务范围车辆未绑定主车辆',
CONVERT(CONCAT('客户 ',s.customer_id,' 的业务范围包含 VIN ',s.vin,',但中台主车辆不存在') USING utf8mb4) COLLATE utf8mb4_unicode_ci,
JSON_OBJECT('scopeVersion',s.source_version,'customerId',CAST(s.customer_id AS CHAR),'customerName',s.customer_name,
'contractCode',s.contract_code,'projectName',s.project_name,'departmentName',s.department_name,'responsibleUserName',s.responsible_user_name)
FROM business_scope_state st
JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY s.vin
WHERE st.id=1 AND st.active_version IS NOT NULL AND b.vin IS NULL
UNION ALL
SELECT
CONVERT(CONCAT('auth-business:',u.id,':',g.vin) USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'AUTH_SCOPE_BUSINESS_MISMATCH','business','major',
CONVERT(g.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci,
CONVERT(COALESCE(b.plate,'') USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'PLATFORM_AUTH','ONEOS','人工授权与 OneOS 业务范围不一致',
CONVERT(CONCAT('客户账号 ',u.username,' 当前授权 VIN 不在同客户的 OneOS 活跃范围内') USING utf8mb4) COLLATE utf8mb4_unicode_ci,
JSON_OBJECT('scopeVersion',st.active_version,'userId',CAST(u.id AS CHAR),'username',u.username,'customerRef',u.customer_ref,
'grantValidFrom',DATE_FORMAT(COALESCE(g.valid_from,g.granted_at),'%Y-%m-%d %H:%i:%s'))
FROM business_scope_state st
JOIN platform_user u ON u.user_type='customer' AND u.status='enabled' AND u.customer_ref<>''
JOIN platform_user_vehicle g ON g.user_id=u.id AND (g.valid_to IS NULL OR g.valid_to>NOW())
LEFT JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
AND BINARY CAST(s.customer_id AS CHAR)=BINARY u.customer_ref AND BINARY s.vin=BINARY g.vin
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL`
func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
where := []string{"1=1"}
args := []any{}
if value := strings.TrimSpace(query.Keyword); value != "" {
like := "%" + value + "%"
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ?)")
args = append(args, like, like, like, like)
}
for column, value := range map[string]string{
"i.rule_code": query.RuleCode, "i.category": query.Category, "i.severity": query.Severity,
} {
if value = strings.TrimSpace(value); value != "" && value != "all" {
where = append(where, column+"=?")
args = append(args, value)
}
}
if status := strings.TrimSpace(query.Status); status == "active" {
where = append(where, "i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
} else if status != "" && status != "all" {
where = append(where, "i.status=?")
args = append(args, status)
}
return strings.Join(where, " AND "), args
}
const reconciliationSelect = `SELECT i.id,i.rule_code,i.category,i.severity,i.status,i.vin,i.plate,i.protocol_a,i.protocol_b,
i.title,i.summary,CAST(i.evidence_json AS CHAR),DATE_FORMAT(i.first_seen_at,'%Y-%m-%d %H:%i:%s'),
DATE_FORMAT(i.last_seen_at,'%Y-%m-%d %H:%i:%s'),i.occurrence_count,
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,i.version
FROM vehicle_reconciliation_issue i `
func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (ReconciliationIssue, error) {
var item ReconciliationIssue
var evidence string
err := scanner.Scan(&item.ID, &item.RuleCode, &item.Category, &item.Severity, &item.Status, &item.VIN, &item.Plate,
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidence, &item.FirstSeenAt, &item.LastSeenAt,
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, &item.Version)
if err == nil {
item.Evidence = map[string]any{}
err = json.Unmarshal([]byte(evidence), &item.Evidence)
}
return item, err
}
func (s *ProductionStore) ReconciliationIssues(ctx context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return Page[ReconciliationIssue]{}, err
}
if query.Limit <= 0 || query.Limit > 200 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
where, args := buildReconciliationWhere(query)
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue i WHERE `+where, args...).Scan(&total); err != nil {
return Page[ReconciliationIssue]{}, err
}
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
rows, err := s.db.QueryContext(ctx, reconciliationSelect+`WHERE `+where+`
ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC LIMIT ? OFFSET ?`, listArgs...)
if err != nil {
return Page[ReconciliationIssue]{}, err
}
defer rows.Close()
items := make([]ReconciliationIssue, 0, query.Limit)
for rows.Next() {
item, err := scanReconciliationIssue(rows)
if err != nil {
return Page[ReconciliationIssue]{}, err
}
items = append(items, item)
}
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
}
func (s *ProductionStore) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationIssue{}, err
}
item, err := scanReconciliationIssue(s.db.QueryRowContext(ctx, reconciliationSelect+`WHERE i.id=?`, id))
if err == sql.ErrNoRows {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
if err != nil {
return ReconciliationIssue{}, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,
DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s')
FROM vehicle_reconciliation_action WHERE issue_id=? ORDER BY created_at,id`, id)
if err != nil {
return ReconciliationIssue{}, err
}
defer rows.Close()
item.Actions = []ReconciliationAction{}
for rows.Next() {
var action ReconciliationAction
if err := rows.Scan(&action.ID, &action.Action, &action.FromStatus, &action.ToStatus, &action.Actor, &action.Note, &action.CreatedAt); err != nil {
return ReconciliationIssue{}, err
}
item.Actions = append(item.Actions, action)
}
return item, rows.Err()
}
func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationIssue{}, err
}
allowed := map[string]bool{
"pending": true, "confirmed_source_a": true, "confirmed_source_b": true,
"no_action": true, "fixed": true,
}
request.Status = strings.TrimSpace(request.Status)
request.Note = strings.TrimSpace(request.Note)
if !allowed[request.Status] {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_STATUS_INVALID", Message: "差异处置状态无效"}
}
if request.Status != "pending" && request.Note == "" {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOTE_REQUIRED", Message: "确认结论必须填写说明"}
}
if len([]rune(request.Note)) > 500 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOTE_TOO_LONG", Message: "处置说明不能超过 500 个字符"}
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return ReconciliationIssue{}, err
}
defer tx.Rollback()
var currentStatus string
var currentVersion int
if err := tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(&currentStatus, &currentVersion); err != nil {
if err == sql.ErrNoRows {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
return ReconciliationIssue{}, err
}
if currentVersion != request.Version {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
}
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
status=?,resolution_note=?,resolved_by=?,
recovered_at=CASE WHEN ? IN ('fixed','no_action') THEN NOW(3) ELSE NULL END,
version=version+1
WHERE id=? AND version=?`, request.Status, request.Note, request.Actor, request.Status, id, request.Version)
if err != nil {
return ReconciliationIssue{}, err
}
affected, _ := result.RowsAffected()
if affected != 1 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录更新冲突"}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'review',?,?,?,?)`, id, currentStatus, request.Status, request.Actor, request.Note); err != nil {
return ReconciliationIssue{}, err
}
if err := tx.Commit(); err != nil {
return ReconciliationIssue{}, err
}
return s.ReconciliationIssue(ctx, id)
}
const reconciliationTrendSQL = `SELECT DATE_FORMAT(MIN(finished_at),'%Y-%m-%d'),
CAST(SUBSTRING_INDEX(GROUP_CONCAT(detected_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
SUM(new_count),
CAST(SUBSTRING_INDEX(GROUP_CONCAT(active_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
SUM(recovered_count)
FROM vehicle_reconciliation_run
WHERE status='completed' AND finished_at>=DATE_SUB(CURDATE(),INTERVAL ? DAY)
GROUP BY DATE(finished_at) ORDER BY DATE(finished_at)`
func (s *ProductionStore) ReconciliationSummary(ctx context.Context, days int) (ReconciliationSummary, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationSummary{}, err
}
if days <= 0 || days > 90 {
days = 30
}
var result ReconciliationSummary
if err := s.db.QueryRowContext(ctx, `SELECT
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
COALESCE(SUM(status='pending'),0),
COALESCE(SUM(status IN ('confirmed_source_a','confirmed_source_b')),0),
COALESCE(SUM(status='recovered'),0),
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b') AND first_seen_at<DATE_SUB(NOW(),INTERVAL 24 HOUR)),0)
FROM vehicle_reconciliation_issue`).Scan(&result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
return ReconciliationSummary{}, err
}
var err error
if result.ByRule, err = s.reconciliationBuckets(ctx, "rule_code"); err != nil {
return ReconciliationSummary{}, err
}
if result.BySeverity, err = s.reconciliationBuckets(ctx, "severity"); err != nil {
return ReconciliationSummary{}, err
}
rows, err := s.db.QueryContext(ctx, reconciliationTrendSQL, days-1)
if err != nil {
return ReconciliationSummary{}, err
}
defer rows.Close()
result.Trend = []ReconciliationTrendPoint{}
for rows.Next() {
var item ReconciliationTrendPoint
if err := rows.Scan(&item.Date, &item.Detected, &item.New, &item.Active, &item.Recovered); err != nil {
return ReconciliationSummary{}, err
}
result.Trend = append(result.Trend, item)
}
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(DATE_FORMAT(MAX(finished_at),'%Y-%m-%dT%H:%i:%s.%fZ'),'')
FROM vehicle_reconciliation_run WHERE status='completed'`).Scan(&result.LastRunAt)
result.AsOf = time.Now().Format(time.RFC3339)
return result, rows.Err()
}
func (s *ProductionStore) reconciliationBuckets(ctx context.Context, column string) ([]ReconciliationBucket, error) {
if column != "rule_code" && column != "severity" {
return nil, fmt.Errorf("unsupported reconciliation bucket")
}
rows, err := s.db.QueryContext(ctx, `SELECT `+column+`,COUNT(*) FROM vehicle_reconciliation_issue
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')
GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ReconciliationBucket{}
for rows.Next() {
var item ReconciliationBucket
if err := rows.Scan(&item.Name, &item.Count); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,60 @@
package platform
import (
"strings"
"testing"
)
func TestReconciliationSQLUsesCollationSafeCrossTableKeys(t *testing.T) {
checks := map[string][]string{
"coverage": {
"BINARY b.vin=BINARY s.vin",
"BINARY s.vin=BINARY b.vin",
},
"position": {
"BINARY b.vin=BINARY a.vin",
"BINARY binding.vin=BINARY a.vin",
},
"mileage": {
"BINARY b.vin=BINARY m.vin",
},
"fleet count": {
"SELECT CAST(vin AS BINARY) vin FROM vehicle_identity_binding",
"UNION SELECT CAST(vin AS BINARY) vin FROM vehicle_realtime_snapshot",
"BINARY b.vin=BINARY s.vin",
},
"business scope": {
"BINARY s.source_version=BINARY st.active_version",
"BINARY b.vin=BINARY s.vin",
"BINARY CAST(s.customer_id AS CHAR)=BINARY u.customer_ref",
"BINARY s.vin=BINARY g.vin",
"BINARY b.vin=BINARY g.vin",
"CONVERT(s.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci",
"CONVERT(g.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci",
"CONVERT(COALESCE(b.plate,'') USING utf8mb4) COLLATE utf8mb4_unicode_ci",
},
}
sqlByName := map[string]string{
"coverage": reconciliationCoverageSQL,
"position": reconciliationPositionSQL,
"mileage": reconciliationMileageSQL,
"fleet count": reconciliationFleetCountSQL,
"business scope": reconciliationBusinessScopeSQL,
}
for name, fragments := range checks {
for _, fragment := range fragments {
if !strings.Contains(sqlByName[name], fragment) {
t.Errorf("%s reconciliation SQL is missing %q", name, fragment)
}
}
}
}
func TestReconciliationTrendSQLSupportsOnlyFullGroupBy(t *testing.T) {
if !strings.Contains(reconciliationTrendSQL, "DATE_FORMAT(MIN(finished_at),'%Y-%m-%d')") {
t.Fatalf("trend date must be derived from an aggregate: %s", reconciliationTrendSQL)
}
if strings.Contains(reconciliationTrendSQL, "SELECT DATE_FORMAT(DATE(finished_at)") {
t.Fatalf("trend query must not select a non-aggregated finished_at expression: %s", reconciliationTrendSQL)
}
}

View File

@@ -38,6 +38,9 @@ import type {
Page,
QualitySummary,
QualityIssueRow,
ReconciliationIssue,
ReconciliationQuery,
ReconciliationSummary,
RawFrameRow,
RealtimeLocationRow,
SourceReadinessPlan,
@@ -292,6 +295,22 @@ export const api = {
})
}
),
reconciliationSummary: (days = 30, signal?: AbortSignal) => request<ReconciliationSummary>(
`/api/v2/reconciliation/summary?days=${days}`,
withSignal(undefined, signal)
),
reconciliationIssues: (query: ReconciliationQuery, signal?: AbortSignal) => request<Page<ReconciliationIssue>>(
'/api/v2/reconciliation/issues',
withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)
),
reconciliationIssue: (id: string, signal?: AbortSignal) => request<ReconciliationIssue>(
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}`,
withSignal(undefined, signal)
),
updateReconciliationIssue: (id: string, input: { version: number; status: string; note: string }) => request<ReconciliationIssue>(
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/actions`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
),
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),

View File

@@ -957,6 +957,75 @@ export interface RuntimeInfo {
platformRelease?: string;
}
export interface ReconciliationQuery {
keyword?: string;
ruleCode?: string;
category?: string;
severity?: string;
status?: string;
limit?: number;
offset?: number;
}
export interface ReconciliationAction {
id: number;
action: string;
fromStatus: string;
toStatus: string;
actor: string;
note: string;
createdAt: string;
}
export interface ReconciliationIssue {
id: string;
ruleCode: string;
category: string;
severity: string;
status: string;
vin: string;
plate: string;
protocolA: string;
protocolB: string;
title: string;
summary: string;
evidence: Record<string, unknown>;
firstSeenAt: string;
lastSeenAt: string;
occurrenceCount: number;
recoveredAt: string;
resolutionNote: string;
resolvedBy: string;
version: number;
actions?: ReconciliationAction[];
}
export interface ReconciliationBucket {
name: string;
count: number;
}
export interface ReconciliationTrendPoint {
date: string;
detected: number;
new: number;
active: number;
recovered: number;
}
export interface ReconciliationSummary {
active: number;
pending: number;
confirmed: number;
recovered: number;
overSla: number;
byRule: ReconciliationBucket[];
bySeverity: ReconciliationBucket[];
trend: ReconciliationTrendPoint[];
lastRunAt: string;
asOf: string;
}
export interface MapReverseGeocode {
provider: string;
longitude: number;

View File

@@ -5,7 +5,8 @@ import OperationsPage from './OperationsPage';
const mocks = vi.hoisted(() => ({
opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(),
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn()
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
}));
vi.mock('../../api/client', () => ({ api: mocks }));
@@ -13,8 +14,60 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes
function seedSession() {
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
mocks.reconciliationSummary.mockResolvedValue({
active: 1, pending: 1, confirmed: 0, recovered: 2, overSla: 1,
byRule: [{ name: 'POSITION_DRIFT', count: 1 }], bySeverity: [{ name: 'major', count: 1 }],
trend: [{ date: '2026-07-16', detected: 1, new: 1, active: 1, recovered: 0 }],
lastRunAt: '2026-07-16 02:15:00', asOf: '2026-07-16 10:00:00'
});
mocks.reconciliationIssues.mockResolvedValue({
items: [{
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1
}],
total: 1, limit: 50, offset: 0
});
mocks.reconciliationIssue.mockResolvedValue({
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1,
actions: [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'pending', actor: 'reconciliation-evaluator', note: '规则首次发现差异', createdAt: '2026-07-16 08:00:00' }]
});
}
test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
mocks.updateReconciliationIssue.mockResolvedValue({
...(await mocks.reconciliationIssue()),
status: 'confirmed_source_a',
resolutionNote: '来源 A 原始报文可信',
resolvedBy: '平台管理员',
version: 2
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('数据差异中心')).toBeInTheDocument();
fireEvent.click(await screen.findByText('多来源实时位置漂移'));
expect(await screen.findByText('规则证据')).toBeInTheDocument();
expect(screen.getByText('1286')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('处置状态'), { target: { value: 'confirmed_source_a' } });
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
fireEvent.click(screen.getByRole('button', { name: '保存复核结论' }));
await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信'
}));
});
test('reconciles service identities with bound and identity-required vehicles', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
@@ -92,8 +145,12 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
expect(await screen.findByText('粤A00001')).toBeInTheDocument();
fireEvent.click(screen.getByText('粤A00001'));
const candidateButton = await waitFor(() => {
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
expect(button).toBeTruthy();
return button!;
});
fireEvent.click(candidateButton);
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument();

View File

@@ -5,6 +5,7 @@ import { api } from '../../api/client';
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import ReconciliationCenter from './ReconciliationCenter';
function statusLabel(status: string) {
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
@@ -142,6 +143,7 @@ export default function OperationsPage() {
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
return <div className="v2-ops-page">
<header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
<ReconciliationCenter />
<SourceDiagnosticWorkspace />
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}

View File

@@ -0,0 +1,208 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { ReconciliationIssue } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const PAGE_SIZE = 50;
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
const reviewStatuses = [
{ value: 'pending', label: '待处理' },
{ value: 'confirmed_source_a', label: '确认来源 A' },
{ value: 'confirmed_source_b', label: '确认来源 B' },
{ value: 'no_action', label: '无需处理' },
{ value: 'fixed', label: '已修复' }
];
function statusLabel(status: string) {
return {
pending: '待处理',
confirmed_source_a: '确认来源 A',
confirmed_source_b: '确认来源 B',
no_action: '无需处理',
fixed: '已修复',
recovered: '已恢复'
}[status] ?? status;
}
function severityLabel(severity: string) {
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
}
function ruleLabel(rule: string) {
return {
DUPLICATE_PLATE: '重复车牌',
DUPLICATE_PHONE: '重复终端',
UNBOUND_SOURCE: '来源未绑定',
SOURCE_MISSING: '主车无来源',
POSITION_DRIFT: '位置漂移',
MILEAGE_REVERSE: '里程倒退',
MILEAGE_JUMP: '里程跳变',
MILEAGE_SOURCE_DIVERGENCE: '里程来源差异',
FLEET_COUNT_MISMATCH: '车辆总数不一致',
BUSINESS_SCOPE_UNBOUND: '业务车辆未绑定',
AUTH_SCOPE_BUSINESS_MISMATCH: '授权与业务范围不一致'
}[rule] ?? rule;
}
function fmt(value?: string) {
if (!value) return '—';
const parsed = new Date(value.replace(' ', 'T'));
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
}
function evidenceValue(value: unknown) {
if (value == null || value === '') return '—';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
return JSON.stringify(value, null, 2);
}
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
const queryClient = useQueryClient();
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
const [note, setNote] = useState(issue.resolutionNote ?? '');
useEffect(() => {
setStatus(issue.status === 'recovered' ? 'pending' : issue.status);
setNote(issue.resolutionNote ?? '');
}, [issue.id, issue.resolutionNote, issue.status]);
const save = useMutation({
mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }),
onSuccess: async (updated) => {
queryClient.setQueryData(['reconciliation-detail', issue.id], updated);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
]);
}
});
const requiresNote = status !== 'pending';
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
<header>
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
</header>
<div className="v2-reconcile-detail-body">
<section className="v2-reconcile-identity">
<div><small></small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
<div><small></small><strong>{[issue.protocolA, issue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径'}</strong><span> {issue.occurrenceCount.toLocaleString('zh-CN')} </span></div>
<div><small></small><strong>{fmt(issue.lastSeenAt)}</strong><span> {fmt(issue.firstSeenAt)}</span></div>
</section>
<p className="v2-reconcile-summary">{issue.summary}</p>
<section className="v2-reconcile-evidence">
<header><strong></strong><span></span></header>
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
</section>
<section className="v2-reconcile-review">
<header><strong></strong><span> v{issue.version}</span></header>
<label><span></span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
<label><span>{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
</section>
<section className="v2-reconcile-actions">
<header><strong></strong><span>{issue.actions?.length ?? 0} </span></header>
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p></p>}
</section>
</div>
</aside>;
}
export default function ReconciliationCenter() {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [status, setStatus] = useState('active');
const [severity, setSeverity] = useState('all');
const [ruleCode, setRuleCode] = useState('all');
const [offset, setOffset] = useState(0);
const [selectedID, setSelectedID] = useState('');
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
const summary = useQuery({
queryKey: ['reconciliation-summary', 30],
queryFn: ({ signal }) => api.reconciliationSummary(30, signal),
staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
});
const issues = useQuery({
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
queryFn: ({ signal }) => api.reconciliationIssues({
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
}, signal),
staleTime: 20_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const detail = useQuery({
queryKey: ['reconciliation-detail', selectedID],
queryFn: ({ signal }) => api.reconciliationIssue(selectedID, signal),
enabled: Boolean(selectedID),
staleTime: 10_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const maxTrend = useMemo(() => {
let result = 1;
for (const item of summary.data?.trend ?? []) result = Math.max(result, item.active, item.new, item.recovered);
return result;
}, [summary.data?.trend]);
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
const data = summary.data;
const page = issues.data;
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
return <section className="v2-reconcile-center">
<header className="v2-reconcile-heading">
<div><small></small><strong></strong><span></span></div>
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh /></button>
</header>
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<div className="v2-reconcile-kpis">
<article className="is-active"><small></small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article className={data?.overSla ? 'is-overdue' : ''}><small> 24 </small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
</div>
<div className="v2-reconcile-layout">
<div className="v2-reconcile-main">
<div className="v2-reconcile-toolbar">
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="active"></option><option value="pending"></option><option value="confirmed_source_a"> A</option><option value="confirmed_source_b"> B</option><option value="recovered"></option><option value="fixed"></option><option value="no_action"></option><option value="all"></option>
</select>
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
<option value="all"></option><option value="critical"></option><option value="major"></option><option value="minor"></option>
</select>
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
<option value="all"></option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
</select>
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div className="v2-reconcile-table-wrap">
<table className="v2-reconcile-table"><thead><tr><th></th><th> / </th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </span></td>
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
<td><strong>{fmt(item.lastSeenAt)}</strong><span> {fmt(item.firstSeenAt)}</span></td>
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
</tr>)}</tbody>
</table>
{issues.isPending ? <p className="v2-reconcile-empty"></p> : null}
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty"></p> : null}
</div>
<footer className="v2-reconcile-pagination"><span> {(page?.total ?? 0).toLocaleString('zh-CN')} · {activeCount} </span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}></button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}></button></div></footer>
</div>
<aside className="v2-reconcile-trend">
<header><strong> 30 </strong><span> {fmt(data?.lastRunAt)}</span></header>
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
</aside>
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
</div>
</section>;
}

View File

@@ -401,6 +401,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; }
.v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; }
.v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; }
.v2-reconcile-center { position: relative; border: 1px solid #cbd8e8; border-radius: 14px; background: #fff; box-shadow: 0 10px 32px rgba(31,53,80,.09); overflow: hidden; }
.v2-reconcile-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e2e9f2; padding: 12px 16px; background: linear-gradient(105deg,#f7fbff 0%,#f2f7ff 58%,#eef5ff 100%); }.v2-reconcile-heading > div { display: grid; gap: 3px; }.v2-reconcile-heading small { color: #557494; font-size: 11px; }.v2-reconcile-heading strong { color: #17283b; font-size: 18px; }.v2-reconcile-heading span { color: #64758a; font-size: 12px; }.v2-reconcile-heading button { display: inline-flex; height: 36px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid #c5d4e5; border-radius: 8px; background: #fff; padding: 0 13px; color: #355272; cursor: pointer; font-size: 12px; }
.v2-reconcile-kpis { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); border-bottom: 1px solid #e3eaf2; }.v2-reconcile-kpis article { position: relative; min-width: 0; padding: 13px 16px; }.v2-reconcile-kpis article + article::before { position: absolute; inset: 13px auto 13px 0; width: 1px; background: #e4ebf3; content: ''; }.v2-reconcile-kpis small { display: block; color: #748398; font-size: 11px; }.v2-reconcile-kpis strong { display: block; margin: 5px 0 2px; color: #17283b; font-size: 22px; line-height: 1.1; }.v2-reconcile-kpis span { color: #8794a6; font-size: 10px; }.v2-reconcile-kpis article.is-active strong { color: #2464c8; }.v2-reconcile-kpis article.is-overdue strong { color: #c2413d; }
.v2-reconcile-layout { position: relative; display: grid; min-height: 480px; grid-template-columns: minmax(0,1fr) 260px; }.v2-reconcile-main { display: flex; min-width: 0; flex-direction: column; border-right: 1px solid #e3eaf2; }.v2-reconcile-toolbar { display: grid; grid-template-columns: minmax(240px,1fr) repeat(3,minmax(130px,auto)); gap: 8px; border-bottom: 1px solid #e7edf4; padding: 10px 12px; }.v2-reconcile-toolbar select, .v2-reconcile-search { height: 36px; border: 1px solid #d7e0eb; border-radius: 8px; background: #fff; color: #33465c; font-size: 12px; }.v2-reconcile-toolbar select { min-width: 0; padding: 0 10px; }.v2-reconcile-search { display: flex; align-items: center; gap: 8px; padding: 0 11px; }.v2-reconcile-search svg { color: #71849a; }.v2-reconcile-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: #23374e; font: inherit; }
.v2-reconcile-table-wrap { min-height: 360px; flex: 1; overflow: auto; }.v2-reconcile-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }.v2-reconcile-table th { position: sticky; z-index: 1; top: 0; height: 38px; background: #f7f9fc; padding: 0 11px; color: #718095; font-size: 11px; font-weight: 600; text-align: left; }.v2-reconcile-table th:nth-child(1) { width: 72px; }.v2-reconcile-table th:nth-child(2) { width: 27%; }.v2-reconcile-table th:nth-child(3) { width: 19%; }.v2-reconcile-table th:nth-child(4) { width: 15%; }.v2-reconcile-table th:nth-child(5) { width: 21%; }.v2-reconcile-table th:nth-child(6) { width: 112px; }.v2-reconcile-table tbody tr { cursor: pointer; border-top: 1px solid #edf1f6; outline: none; }.v2-reconcile-table tbody tr:hover, .v2-reconcile-table tbody tr:focus-visible { background: #f7fbff; }.v2-reconcile-table tbody tr.is-selected { background: #eef6ff; box-shadow: inset 3px 0 #3478d4; }.v2-reconcile-table td { height: 62px; padding: 8px 11px; vertical-align: middle; }.v2-reconcile-table td strong { display: block; overflow: hidden; color: #263a50; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.v2-reconcile-table td span:not(.v2-reconcile-severity):not(.v2-reconcile-status) { display: block; margin-top: 4px; overflow: hidden; color: #8290a2; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-reconcile-severity, .v2-reconcile-status { display: inline-flex; min-height: 24px; align-items: center; border-radius: 999px; padding: 0 8px; font-size: 10px; font-weight: 600; }.v2-reconcile-severity.is-critical { background: #fff0ef; color: #c53c36; }.v2-reconcile-severity.is-major { background: #fff6e5; color: #a96600; }.v2-reconcile-severity.is-minor { background: #eef4fa; color: #60758d; }.v2-reconcile-status { background: #edf3fb; color: #426485; }.v2-reconcile-status.is-pending { background: #fff4df; color: #a45f00; }.v2-reconcile-status.is-confirmed_source_a, .v2-reconcile-status.is-confirmed_source_b { background: #e9f2ff; color: #2464c8; }.v2-reconcile-status.is-recovered, .v2-reconcile-status.is-fixed { background: #eaf8f2; color: #167759; }
.v2-reconcile-empty { margin: 0; padding: 34px 16px; color: #77879a; font-size: 12px; text-align: center; }.v2-reconcile-pagination { display: flex; min-height: 46px; align-items: center; justify-content: space-between; border-top: 1px solid #e6edf4; padding: 7px 12px; color: #728197; font-size: 11px; }.v2-reconcile-pagination div { display: flex; gap: 6px; }.v2-reconcile-pagination button { height: 30px; border: 1px solid #d5dfeb; border-radius: 7px; background: #fff; padding: 0 11px; color: #405870; cursor: pointer; font-size: 11px; }.v2-reconcile-pagination button:disabled { cursor: not-allowed; opacity: .45; }
.v2-reconcile-trend { padding: 12px; background: #fbfcfe; }.v2-reconcile-trend > header { display: grid; gap: 3px; margin-bottom: 10px; }.v2-reconcile-trend header strong { color: #273b51; font-size: 13px; }.v2-reconcile-trend header span, .v2-reconcile-trend > p { color: #8390a2; font-size: 10px; }.v2-reconcile-trend article { display: grid; min-height: 25px; grid-template-columns: 36px minmax(0,1fr) 28px; align-items: center; gap: 7px; }.v2-reconcile-trend time, .v2-reconcile-trend article b { color: #718197; font-size: 9px; font-weight: 500; }.v2-reconcile-trend article > div { position: relative; height: 12px; border-radius: 3px; background: #edf2f7; overflow: hidden; }.v2-reconcile-trend article i { position: absolute; left: 0; height: 4px; }.v2-reconcile-trend article i.is-active { top: 0; background: #4c7fc4; }.v2-reconcile-trend article i.is-new { top: 4px; background: #e2a237; }.v2-reconcile-trend article i.is-recovered { top: 8px; background: #35a37b; }.v2-reconcile-trend > footer { display: flex; gap: 10px; margin-top: 10px; color: #758398; font-size: 9px; }.v2-reconcile-trend footer span { display: inline-flex; align-items: center; gap: 4px; }.v2-reconcile-trend footer i { width: 7px; height: 7px; border-radius: 2px; }.v2-reconcile-trend footer i.is-active { background: #4c7fc4; }.v2-reconcile-trend footer i.is-new { background: #e2a237; }.v2-reconcile-trend footer i.is-recovered { background: #35a37b; }
.v2-reconcile-detail { position: absolute; z-index: 5; top: 0; right: 0; bottom: 0; width: min(460px,52vw); border-left: 1px solid #c9d7e7; background: #fff; box-shadow: -14px 0 34px rgba(25,48,75,.15); overflow: hidden; }.v2-reconcile-detail > header { display: flex; min-height: 66px; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e3eaf2; padding: 12px 14px; background: #f6f9fd; }.v2-reconcile-detail > header div { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 5px 8px; }.v2-reconcile-detail > header span { border-radius: 999px; background: #edf2f7; padding: 3px 7px; font-size: 10px; }.v2-reconcile-detail > header span.is-critical { background: #fff0ef; color: #c53c36; }.v2-reconcile-detail > header span.is-major { background: #fff6e5; color: #a96600; }.v2-reconcile-detail > header strong { width: 100%; color: #21364d; font-size: 15px; }.v2-reconcile-detail > header small { color: #738297; font-size: 10px; }.v2-reconcile-detail > header button { border: 0; background: transparent; color: #6f8093; cursor: pointer; font-size: 24px; line-height: 1; }.v2-reconcile-detail-body { height: calc(100% - 66px); padding: 12px 14px 20px; overflow: auto; }.v2-reconcile-identity { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); border: 1px solid #e1e8f0; border-radius: 9px; overflow: hidden; }.v2-reconcile-identity div { min-width: 0; padding: 10px; }.v2-reconcile-identity div + div { border-left: 1px solid #e5ebf2; }.v2-reconcile-identity small, .v2-reconcile-identity span { display: block; color: #8491a2; font-size: 9px; }.v2-reconcile-identity strong { display: block; margin: 4px 0; overflow: hidden; color: #2b3e54; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.v2-reconcile-summary { margin: 11px 0; border-radius: 8px; background: #f3f7fb; padding: 10px 12px; color: #4b5e73; font-size: 11px; line-height: 1.55; }
.v2-reconcile-evidence, .v2-reconcile-review, .v2-reconcile-actions { margin-top: 12px; border: 1px solid #e0e7ef; border-radius: 9px; overflow: hidden; }.v2-reconcile-evidence > header, .v2-reconcile-review > header, .v2-reconcile-actions > header { display: flex; min-height: 39px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf2; padding: 0 11px; background: #fafbfd; }.v2-reconcile-evidence header strong, .v2-reconcile-review header strong, .v2-reconcile-actions header strong { color: #34495f; font-size: 11px; }.v2-reconcile-evidence header span, .v2-reconcile-review header span, .v2-reconcile-actions header span { color: #8794a4; font-size: 9px; }.v2-reconcile-evidence dl { margin: 0; }.v2-reconcile-evidence dl div { display: grid; grid-template-columns: 118px minmax(0,1fr); border-top: 1px solid #eef2f6; }.v2-reconcile-evidence dl div:first-child { border-top: 0; }.v2-reconcile-evidence dt { padding: 9px 10px; background: #fafbfd; color: #708095; font-size: 9px; }.v2-reconcile-evidence dd { min-width: 0; margin: 0; padding: 8px 10px; }.v2-reconcile-evidence pre { margin: 0; overflow: auto; color: #30475f; font: 10px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; white-space: pre-wrap; word-break: break-word; }
.v2-reconcile-review { padding-bottom: 11px; }.v2-reconcile-review > header { margin-bottom: 9px; }.v2-reconcile-review > label { display: grid; gap: 5px; margin: 8px 11px; color: #66778b; font-size: 10px; }.v2-reconcile-review select, .v2-reconcile-review textarea { border: 1px solid #d5dfeb; border-radius: 7px; background: #fff; padding: 8px 9px; color: #30465d; font: inherit; }.v2-reconcile-review textarea { min-height: 78px; resize: vertical; }.v2-reconcile-review > button { height: 33px; margin: 3px 11px 0; border: 0; border-radius: 7px; background: #316fc3; padding: 0 13px; color: #fff; cursor: pointer; font-size: 11px; }.v2-reconcile-review > button:disabled { cursor: not-allowed; opacity: .5; }.v2-reconcile-review > p { margin: 7px 11px 0; color: #c23c37; font-size: 10px; }
.v2-reconcile-actions > ol { margin: 0; padding: 9px 12px; list-style: none; }.v2-reconcile-actions li { display: grid; grid-template-columns: 10px minmax(0,1fr); gap: 8px; padding: 7px 0; }.v2-reconcile-actions li > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: #5d83b5; }.v2-reconcile-actions li strong { color: #354b61; font-size: 10px; }.v2-reconcile-actions li p { margin: 3px 0; color: #627489; font-size: 10px; line-height: 1.4; }.v2-reconcile-actions li span { color: #8b97a7; font-size: 9px; }.v2-reconcile-actions > p { margin: 0; padding: 14px; color: #8290a1; font-size: 10px; }
.v2-source-diagnostic { position: relative; border: 1px solid #cfdbea; border-radius: 12px; background: #fff; box-shadow: 0 8px 28px rgba(31,53,80,.08); overflow: hidden; }.v2-source-diagnostic > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf3; padding: 9px 14px; background: linear-gradient(100deg,#f8fbff,#f4f8ff); }.v2-source-diagnostic > header div { display: grid; gap: 2px; }.v2-source-diagnostic > header small { color: #547297; font-size: 9px; }.v2-source-diagnostic > header strong { font-size: 15px; }.v2-source-diagnostic > header span { color: var(--v2-muted); font-size: 9px; }.v2-source-diagnostic > header button, .v2-source-search > button, .v2-source-policy-cell button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 12px; color: #355272; cursor: pointer; font-size: 10px; }.v2-source-diagnostic button:disabled { cursor: not-allowed; opacity: .5; }
.v2-source-search { position: relative; display: flex; gap: 8px; padding: 12px 14px; }.v2-source-search > label { display: flex; min-width: 280px; flex: 1; height: 36px; align-items: center; gap: 8px; border: 1px solid #cbd8e8; border-radius: 8px; padding: 0 11px; color: #6c7c91; }.v2-source-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; font-size: 11px; }.v2-source-search > button { height: 36px; border-color: #2f6fe4; background: #2f6fe4; color: #fff; }.v2-source-candidates { position: absolute; z-index: 12; top: 51px; right: 114px; left: 14px; max-height: 310px; overflow: auto; border: 1px solid #d6e0ec; border-radius: 9px; background: #fff; box-shadow: 0 12px 32px rgba(31,53,80,.18); }.v2-source-candidates > button { display: grid; width: 100%; grid-template-columns: 150px minmax(180px,1fr) auto; gap: 10px; border: 0; border-bottom: 1px solid #edf1f6; background: #fff; padding: 10px 12px; text-align: left; cursor: pointer; }.v2-source-candidates > button:hover { background: #f5f9ff; }.v2-source-candidates strong { font-size: 11px; }.v2-source-candidates span, .v2-source-candidates em, .v2-source-candidates p { color: var(--v2-muted); font-size: 9px; font-style: normal; }.v2-source-candidates p { margin: 0; padding: 14px; }.v2-source-candidates footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #e5ebf3; background: #fff; padding: 7px 10px; }.v2-source-candidates footer div { display: flex; gap: 5px; }.v2-source-candidates footer button { border: 1px solid #d2dce8; border-radius: 5px; background: #fff; padding: 4px 8px; color: #52657d; font-size: 8px; }
.v2-source-empty { display: grid; min-height: 110px; place-content: center; gap: 6px; color: var(--v2-muted); text-align: center; }.v2-source-empty strong { color: #42556e; font-size: 13px; }.v2-source-empty span { font-size: 10px; }.v2-source-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-block: 1px solid #e7edf4; background: #fafcff; }.v2-source-summary article { min-width: 0; padding: 11px 14px; }.v2-source-summary article + article { border-left: 1px solid #e7edf4; }.v2-source-summary small, .v2-source-summary span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-summary strong { display: block; margin: 5px 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-recommendation { margin: 12px 14px 8px; border-left: 3px solid #2f6fe4; border-radius: 6px; background: #f3f7ff; padding: 9px 11px; }.v2-source-recommendation strong { font-size: 10px; }.v2-source-recommendation p { margin: 4px 0; color: #405774; font-size: 9px; line-height: 1.5; }.v2-source-recommendation span, .v2-source-readonly { color: #728299; font-size: 8px; }.v2-source-readonly { margin: 0 14px 8px; border-radius: 6px; background: #fff8e8; padding: 7px 9px; color: #966500; }
@@ -1135,6 +1147,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rules { display: flex; flex-direction: column; }
.v2-alert-rule-list { max-height: 300px; flex: none; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
.v2-reconcile-kpis { grid-template-columns: repeat(3,1fr); }.v2-reconcile-layout { grid-template-columns: minmax(0,1fr) 220px; }.v2-reconcile-toolbar { grid-template-columns: minmax(220px,1fr) repeat(2,minmax(120px,auto)); }.v2-reconcile-toolbar select:last-child { grid-column: span 2; }
.v2-source-summary { grid-template-columns: repeat(2,1fr); }.v2-source-summary article:nth-child(3) { border-left: 0; }.v2-source-summary article:nth-child(n+3) { border-top: 1px solid #e7edf4; }
}
@@ -1454,6 +1467,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
.v2-reconcile-heading { align-items: flex-start; flex-direction: column; }.v2-reconcile-heading button { width: 100%; justify-content: center; }.v2-reconcile-kpis { grid-template-columns: 1fr 1fr; }.v2-reconcile-kpis article + article::before { display: none; }.v2-reconcile-kpis article { border-bottom: 1px solid #e5ebf2; }.v2-reconcile-layout { display: block; min-height: 520px; }.v2-reconcile-main { border-right: 0; }.v2-reconcile-toolbar { grid-template-columns: 1fr 1fr; }.v2-reconcile-search { grid-column: 1 / -1; }.v2-reconcile-toolbar select:last-child { grid-column: 1 / -1; }.v2-reconcile-trend { border-top: 1px solid #e3eaf2; }.v2-reconcile-detail { position: fixed; inset: 54px 0 0; width: auto; border-left: 0; }.v2-reconcile-identity { grid-template-columns: 1fr; }.v2-reconcile-identity div + div { border-top: 1px solid #e5ebf2; border-left: 0; }.v2-reconcile-evidence dl div { grid-template-columns: 96px minmax(0,1fr); }
.v2-source-diagnostic > header { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-source-diagnostic > header button { width: 100%; }.v2-source-search { flex-direction: column; }.v2-source-search > label { width: auto; min-width: 0; height: 42px; }.v2-source-search > button { width: 100%; }.v2-source-candidates { top: 61px; right: 14px; }.v2-source-candidates > button { grid-template-columns: 1fr; gap: 3px; }.v2-source-summary { grid-template-columns: 1fr; }.v2-source-summary article + article, .v2-source-summary article:nth-child(3) { border-top: 1px solid #e7edf4; border-left: 0; }.v2-source-audit > header { height: auto; align-items: flex-start; flex-direction: column; gap: 3px; padding-block: 7px; }.v2-source-audit li { grid-template-columns: 35px minmax(0,1fr); }.v2-source-audit li em { grid-column: 2; }
.v2-mileage-page { gap: 10px; padding: 12px 8px 18px; }
.v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; }