feat(operations): add auditable source provider maintenance
This commit is contained in:
@@ -69,7 +69,7 @@ COALESCE(NULLIF(b.oem, ''), '') AS oem,
|
||||
COALESCE(p.model_name, '') AS model_name,
|
||||
COALESCE(p.company_name, '') AS company_name,
|
||||
COALESCE(s.protocol, '') AS protocol,
|
||||
COALESCE(NULLIF(TRIM(s.platform_name), ''), NULLIF(TRIM(ls.source_code), ''), '') AS provider,
|
||||
COALESCE(NULLIF(TRIM(provider.provider_name), ''), NULLIF(TRIM(s.platform_name), ''), NULLIF(TRIM(ls.source_code), ''), '') AS provider,
|
||||
COALESCE(DATE_FORMAT(s.access_first_seen_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS first_seen_at,
|
||||
COALESCE(s.access_first_seen_source, '') AS first_seen_source,
|
||||
COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s.%f'), '') AS event_time,
|
||||
@@ -92,6 +92,10 @@ LEFT JOIN vehicle_profile p ON p.vin = v.vin
|
||||
LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin
|
||||
LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol
|
||||
LEFT JOIN vehicle_realtime_location_source ls ON ls.vin = l.vin AND ls.protocol = l.protocol AND ls.source_key = l.source_key
|
||||
LEFT JOIN platform_vehicle_source_provider provider
|
||||
ON BINARY provider.vin = BINARY l.vin
|
||||
AND BINARY provider.protocol = BINARY l.protocol
|
||||
AND BINARY provider.source_key = BINARY l.source_key
|
||||
LEFT JOIN (
|
||||
SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km
|
||||
FROM vehicle_daily_mileage
|
||||
|
||||
@@ -475,6 +475,15 @@ func TestHandlerVehicleSourceDiagnosticAndPolicyUpdate(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"version":2`) {
|
||||
t.Fatalf("policy update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload = fmt.Sprintf(`{"version":2,"providerName":"G7s","providerEvidence":"GPS 运维终端清单","enabled":true,"priority":%d,"remark":"人工核验"}`, source.Priority)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources/"+source.SourceRef, strings.NewReader(payload)).WithContext(ctx)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"version":3`) ||
|
||||
!strings.Contains(rec.Body.String(), `"providerOverride":"G7s"`) ||
|
||||
strings.Contains(rec.Body.String(), `"sourceKey"`) {
|
||||
t.Fatalf("provider update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
|
||||
|
||||
@@ -17,6 +17,8 @@ type MockStore struct {
|
||||
accessThresholds AccessThresholdConfig
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
alertMu sync.RWMutex
|
||||
@@ -38,9 +40,11 @@ func NewMockStore() *MockStore {
|
||||
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88},
|
||||
}
|
||||
store := &MockStore{
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
|
||||
sourceProviders: map[string]string{},
|
||||
sourcePolicyRemarks: map[string]string{},
|
||||
profiles: map[string]VehicleProfile{
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
},
|
||||
@@ -246,18 +250,37 @@ func (m *MockStore) SaveVehicleSourcePolicy(_ context.Context, update vehicleSou
|
||||
if current.Version != update.Version {
|
||||
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if update.CurrentEnabled == update.Enabled && update.CurrentPriority == update.Priority && update.CurrentRemark == update.Remark {
|
||||
providerKey := update.VIN + "\x00" + update.Protocol + "\x00" + update.SourceKey
|
||||
currentProvider := m.sourceProviders[providerKey]
|
||||
policyChanged := update.CurrentEnabled != update.Enabled || update.CurrentPriority != update.Priority || update.CurrentRemark != update.Remark
|
||||
providerChanged := currentProvider != update.ProviderName
|
||||
if !policyChanged && !providerChanged {
|
||||
return current, nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
current.Version++
|
||||
current.UpdatedBy = update.Actor
|
||||
current.UpdatedAt = now
|
||||
current.Audit = append([]VehicleSourcePolicyAudit{{
|
||||
Version: current.Version, Protocol: update.Protocol, SourceRef: update.SourceRef,
|
||||
SourceLabel: update.SourceLabel, Actor: update.Actor, ChangedAt: now,
|
||||
Summary: update.SourceLabel + ":" + enabledLabel(update.CurrentEnabled) + "→" + enabledLabel(update.Enabled),
|
||||
}}, current.Audit...)
|
||||
if policyChanged {
|
||||
m.sourcePolicyRemarks[providerKey] = update.Remark
|
||||
current.Audit = append([]VehicleSourcePolicyAudit{{
|
||||
Version: current.Version, ChangeType: "policy", Protocol: update.Protocol, SourceRef: update.SourceRef,
|
||||
SourceLabel: update.SourceLabel, Actor: update.Actor, ChangedAt: now,
|
||||
Summary: update.SourceLabel + ":" + enabledLabel(update.CurrentEnabled) + "→" + enabledLabel(update.Enabled),
|
||||
}}, current.Audit...)
|
||||
}
|
||||
if providerChanged {
|
||||
if update.ProviderName == "" {
|
||||
delete(m.sourceProviders, providerKey)
|
||||
} else {
|
||||
m.sourceProviders[providerKey] = update.ProviderName
|
||||
}
|
||||
current.Audit = append([]VehicleSourcePolicyAudit{{
|
||||
Version: current.Version, ChangeType: "provider", Protocol: update.Protocol, SourceRef: update.SourceRef,
|
||||
SourceLabel: firstNonEmpty(update.ProviderName, currentProvider, "未维护提供方"), Actor: update.Actor, ChangedAt: now,
|
||||
Summary: update.SourceLabel + ":提供方 " + providerLabel(currentProvider) + "→" + providerLabel(update.ProviderName) + ";核验依据:" + update.ProviderEvidence,
|
||||
}}, current.Audit...)
|
||||
}
|
||||
m.sourcePolicies[update.VIN] = current
|
||||
return current, nil
|
||||
}
|
||||
@@ -282,7 +305,7 @@ func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date st
|
||||
lonJT2, latJT2, speedJT2, mileageJT2 := 113.2675, 23.1315, 40.2, 48794.1
|
||||
firstGB, latestGB, dailyGB := 48782.3, 48798.9, 16.6
|
||||
firstJT, latestJT, dailyJT := 48781.7, 48797.6, 15.9
|
||||
return VehicleSourceEvidence{
|
||||
evidence := VehicleSourceEvidence{
|
||||
VIN: vin, Plate: "粤AG18312", MileageDate: date,
|
||||
LocationSources: []VehicleLocationSourceEvidence{
|
||||
{Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, Online: true, QualityStatus: "OK", Longitude: &lonGB, Latitude: &latGB, SpeedKmh: &speedGB, TotalMileageKm: &mileageGB, SOCPercent: &socGB, EventTime: "2026-07-16 16:20:08", ReceivedAt: "2026-07-16 16:20:09", sourceKey: "GB32960:canonical"},
|
||||
@@ -293,7 +316,18 @@ func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date st
|
||||
{Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, QualityStatus: "OK", FirstTotalMileageKm: &firstGB, LatestTotalMileageKm: &latestGB, DailyMileageKm: &dailyGB, SampleCount: 1080, FirstEventTime: "2026-07-16 00:00:10", LatestEventTime: "2026-07-16 16:20:08", sourceKey: "gb:factory"},
|
||||
{Protocol: "JT808", SourceLabel: "G7", TerminalLabel: "终端 133****5425", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 20, QualityStatus: "OK", FirstTotalMileageKm: &firstJT, LatestTotalMileageKm: &latestJT, DailyMileageKm: &dailyJT, SampleCount: 4320, FirstEventTime: "2026-07-16 00:00:03", LatestEventTime: "2026-07-16 16:20:06", sourceKey: "jt808:g7"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
m.sourcePolicyMu.RLock()
|
||||
for index := range evidence.LocationSources {
|
||||
source := &evidence.LocationSources[index]
|
||||
if provider := m.sourceProviders[vin+"\x00"+source.Protocol+"\x00"+source.sourceKey]; provider != "" {
|
||||
source.ProviderOverride = provider
|
||||
source.SourceLabel = provider
|
||||
}
|
||||
source.PolicyRemark = m.sourcePolicyRemarks[vin+"\x00"+source.Protocol+"\x00"+source.sourceKey]
|
||||
}
|
||||
m.sourcePolicyMu.RUnlock()
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
|
||||
|
||||
@@ -1041,6 +1041,7 @@ type VehicleSourceEvidence struct {
|
||||
type VehicleLocationSourceEvidence struct {
|
||||
Protocol string `json:"protocol"`
|
||||
SourceLabel string `json:"sourceLabel"`
|
||||
ProviderOverride string `json:"providerOverride"`
|
||||
TerminalLabel string `json:"terminalLabel"`
|
||||
SourceKind string `json:"sourceKind"`
|
||||
SourceRef string `json:"sourceRef,omitempty"`
|
||||
@@ -1048,6 +1049,7 @@ type VehicleLocationSourceEvidence struct {
|
||||
Recommended bool `json:"recommended"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
PolicyRemark string `json:"policyRemark"`
|
||||
Online bool `json:"online"`
|
||||
QualityStatus string `json:"qualityStatus"`
|
||||
QualityReason string `json:"qualityReason"`
|
||||
@@ -1110,6 +1112,7 @@ type VehicleSourcePolicyConfig struct {
|
||||
|
||||
type VehicleSourcePolicyAudit struct {
|
||||
Version int `json:"version"`
|
||||
ChangeType string `json:"changeType"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
SourceLabel string `json:"sourceLabel"`
|
||||
@@ -1119,12 +1122,14 @@ type VehicleSourcePolicyAudit struct {
|
||||
}
|
||||
|
||||
type VehicleSourcePolicyUpdate struct {
|
||||
Version int `json:"version"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
Remark string `json:"remark"`
|
||||
Actor string `json:"actor"`
|
||||
Version int `json:"version"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
ProviderName string `json:"providerName"`
|
||||
ProviderEvidence string `json:"providerEvidence"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
Remark string `json:"remark"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
|
||||
type vehicleSourcePolicyStoreUpdate struct {
|
||||
@@ -1136,6 +1141,7 @@ type vehicleSourcePolicyStoreUpdate struct {
|
||||
CurrentEnabled bool
|
||||
CurrentPriority int
|
||||
CurrentRemark string
|
||||
CurrentProvider string
|
||||
}
|
||||
|
||||
type vehicleLocationSourceHistory struct {
|
||||
|
||||
@@ -262,6 +262,36 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi
|
||||
if updated.Policy.Version != 2 || len(updated.Policy.Audit) != 1 || updated.Policy.Audit[0].Actor != "平台管理员" {
|
||||
t.Fatalf("source policy update must version and audit: %+v", updated.Policy)
|
||||
}
|
||||
if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s",
|
||||
Enabled: false, Priority: source.Priority,
|
||||
}); err == nil {
|
||||
t.Fatal("provider maintenance must require an authoritative evidence note")
|
||||
}
|
||||
updated, err = service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s",
|
||||
ProviderEvidence: "GPS 运维终端清单 2026-07-16",
|
||||
Enabled: false, Priority: source.Priority, Remark: "人工核验",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin provider update failed: %v", err)
|
||||
}
|
||||
if updated.Policy.Version != 3 || updated.Policy.Audit[0].ChangeType != "provider" {
|
||||
t.Fatalf("provider update must share optimistic versioning and audit: %+v", updated.Policy)
|
||||
}
|
||||
if !strings.Contains(updated.Policy.Audit[0].Summary, "核验依据:GPS 运维终端清单 2026-07-16") ||
|
||||
strings.Contains(updated.Policy.Audit[0].Summary, "人工核验") {
|
||||
t.Fatalf("provider evidence and policy remark must remain separate: %+v", updated.Policy.Audit[0])
|
||||
}
|
||||
providerFound := false
|
||||
for _, item := range updated.Evidence.LocationSources {
|
||||
if item.SourceRef == source.SourceRef && item.ProviderOverride == "G7s" && item.SourceLabel == "G7s" {
|
||||
providerFound = true
|
||||
}
|
||||
}
|
||||
if !providerFound {
|
||||
t.Fatalf("provider update must immediately affect source evidence: %+v", updated.Evidence.LocationSources)
|
||||
}
|
||||
if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority,
|
||||
}); err == nil {
|
||||
|
||||
@@ -117,16 +117,24 @@ func (s *ProductionStore) locationSourceEvidence(ctx context.Context, vin string
|
||||
for _, source := range canonical {
|
||||
selected[source.row.Protocol] = source.row.sourceKey
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key, s.source_code, s.source_kind, s.phone, s.device_id,
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key,
|
||||
COALESCE(NULLIF(TRIM(provider.provider_name), ''), NULLIF(TRIM(s.source_code), ''), ''),
|
||||
COALESCE(provider.provider_name, ''),
|
||||
s.source_kind, s.phone, s.device_id,
|
||||
DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s'), s.latitude, s.longitude, s.speed_kmh,
|
||||
s.total_mileage_km, s.soc_percent, DATE_FORMAT(s.received_at, '%Y-%m-%d %H:%i:%s'),
|
||||
s.quality_status, s.quality_reason,
|
||||
COALESCE(p.enabled, 1),
|
||||
COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END),
|
||||
COALESCE(p.remark, ''),
|
||||
CASE WHEN s.received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END
|
||||
FROM vehicle_realtime_location_source s
|
||||
LEFT JOIN vehicle_location_source_policy p
|
||||
ON p.vin = s.vin AND p.protocol = s.protocol AND p.source_key = s.source_key
|
||||
LEFT JOIN platform_vehicle_source_provider provider
|
||||
ON BINARY provider.vin = BINARY s.vin
|
||||
AND BINARY provider.protocol = BINARY s.protocol
|
||||
AND BINARY provider.source_key = BINARY s.source_key
|
||||
WHERE s.vin = ?
|
||||
ORDER BY s.protocol, COALESCE(p.enabled, 1) DESC,
|
||||
COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END),
|
||||
@@ -144,9 +152,9 @@ LIMIT 50`, vin)
|
||||
var latitude, longitude, speed, mileage, soc sql.NullFloat64
|
||||
var enabled, online int
|
||||
if err := rows.Scan(
|
||||
&row.Protocol, &row.sourceKey, &sourceCode, &row.SourceKind, &phone, &deviceID,
|
||||
&row.Protocol, &row.sourceKey, &sourceCode, &row.ProviderOverride, &row.SourceKind, &phone, &deviceID,
|
||||
&eventTime, &latitude, &longitude, &speed, &mileage, &soc, &receivedAt,
|
||||
&row.QualityStatus, &qualityReason, &enabled, &row.Priority, &online,
|
||||
&row.QualityStatus, &qualityReason, &enabled, &row.Priority, &row.PolicyRemark, &online,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
update.SourceRef = strings.ToLower(strings.TrimSpace(update.SourceRef))
|
||||
update.ProviderName = strings.TrimSpace(update.ProviderName)
|
||||
update.ProviderEvidence = strings.TrimSpace(update.ProviderEvidence)
|
||||
update.Remark = strings.TrimSpace(update.Remark)
|
||||
if vin == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"}
|
||||
@@ -112,6 +114,12 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
if len([]rune(update.Remark)) > 200 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REMARK_TOO_LONG", Message: "策略备注不能超过 200 个字符"}
|
||||
}
|
||||
if len([]rune(update.ProviderName)) > 128 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_TOO_LONG", Message: "提供方名称不能超过 128 个字符"}
|
||||
}
|
||||
if len([]rune(update.ProviderEvidence)) > 255 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_EVIDENCE_TOO_LONG", Message: "提供方核验依据不能超过 255 个字符"}
|
||||
}
|
||||
update.Actor = strings.TrimSpace(update.Actor)
|
||||
if update.Actor == "" {
|
||||
update.Actor = ActorFromContext(ctx)
|
||||
@@ -135,6 +143,10 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
if selected == nil || selected.sourceKey == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"}
|
||||
}
|
||||
providerChanged := strings.TrimSpace(selected.ProviderOverride) != update.ProviderName
|
||||
if providerChanged && update.ProviderEvidence == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_REASON_REQUIRED", Message: "维护提供方时必须填写权威资料来源或核验说明"}
|
||||
}
|
||||
store, ok := s.store.(vehicleSourcePolicyStore)
|
||||
if !ok {
|
||||
return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured")
|
||||
@@ -147,6 +159,8 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
SourceLabel: selected.SourceLabel,
|
||||
CurrentEnabled: selected.Enabled,
|
||||
CurrentPriority: selected.Priority,
|
||||
CurrentRemark: selected.PolicyRemark,
|
||||
CurrentProvider: selected.ProviderOverride,
|
||||
}); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
|
||||
@@ -43,19 +43,28 @@ FROM platform_vehicle_source_policy_version WHERE vin = ?`, vin).Scan(&config.Ve
|
||||
if updatedAt.Valid {
|
||||
config.UpdatedAt = normalizeAccessTime(updatedAt.String)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT version, protocol, source_ref, source_label, actor,
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT version, change_type, protocol, source_ref, source_label, actor,
|
||||
DATE_FORMAT(changed_at, '%Y-%m-%d %H:%i:%s'), summary
|
||||
FROM platform_vehicle_source_policy_audit
|
||||
WHERE vin = ?
|
||||
FROM (
|
||||
SELECT version, 'policy' AS change_type, protocol, source_ref, source_label, actor, changed_at, summary, id
|
||||
FROM platform_vehicle_source_policy_audit
|
||||
WHERE vin = ?
|
||||
UNION ALL
|
||||
SELECT version, 'provider' AS change_type, protocol, source_ref,
|
||||
COALESCE(NULLIF(new_provider, ''), NULLIF(old_provider, ''), '未维护提供方') AS source_label,
|
||||
actor, changed_at, summary, id
|
||||
FROM platform_vehicle_source_provider_audit
|
||||
WHERE vin = ?
|
||||
) combined_audit
|
||||
ORDER BY changed_at DESC, id DESC
|
||||
LIMIT 20`, vin)
|
||||
LIMIT 20`, vin, vin)
|
||||
if err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item VehicleSourcePolicyAudit
|
||||
if err := rows.Scan(&item.Version, &item.Protocol, &item.SourceRef, &item.SourceLabel, &item.Actor, &item.ChangedAt, &item.Summary); err != nil {
|
||||
if err := rows.Scan(&item.Version, &item.ChangeType, &item.Protocol, &item.SourceRef, &item.SourceLabel, &item.Actor, &item.ChangedAt, &item.Summary); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
item.ChangedAt = normalizeAccessTime(item.ChangedAt)
|
||||
@@ -85,6 +94,7 @@ WHERE vin = ? FOR UPDATE`, update.VIN).Scan(¤tVersion); err != nil {
|
||||
currentEnabled := update.CurrentEnabled
|
||||
currentPriority := update.CurrentPriority
|
||||
currentRemark := update.CurrentRemark
|
||||
currentProvider := strings.TrimSpace(update.CurrentProvider)
|
||||
var enabled int
|
||||
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
|
||||
FROM vehicle_location_source_policy
|
||||
@@ -96,20 +106,54 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||
} else if err != sql.ErrNoRows {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
if currentEnabled == update.Enabled && currentPriority == update.Priority && strings.TrimSpace(currentRemark) == update.Remark {
|
||||
err = tx.QueryRowContext(ctx, `SELECT provider_name
|
||||
FROM platform_vehicle_source_provider
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||
update.VIN, update.Protocol, update.SourceKey,
|
||||
).Scan(¤tProvider)
|
||||
if err == sql.ErrNoRows {
|
||||
currentProvider = ""
|
||||
} else if err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
policyChanged := currentEnabled != update.Enabled ||
|
||||
currentPriority != update.Priority ||
|
||||
strings.TrimSpace(currentRemark) != update.Remark
|
||||
providerChanged := strings.TrimSpace(currentProvider) != update.ProviderName
|
||||
if !policyChanged && !providerChanged {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
return s.VehicleSourcePolicy(ctx, update.VIN)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_location_source_policy
|
||||
if policyChanged {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_location_source_policy
|
||||
(vin, protocol, source_key, enabled, priority, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority),
|
||||
remark = VALUES(remark), updated_at = CURRENT_TIMESTAMP`,
|
||||
update.VIN, update.Protocol, update.SourceKey, boolInt(update.Enabled), update.Priority, update.Remark,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
update.VIN, update.Protocol, update.SourceKey, boolInt(update.Enabled), update.Priority, update.Remark,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
}
|
||||
if providerChanged {
|
||||
if update.ProviderName == "" {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_vehicle_source_provider
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||
update.VIN, update.Protocol, update.SourceKey,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_provider
|
||||
(vin, protocol, source_key, source_ref, provider_name, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE source_ref = VALUES(source_ref), provider_name = VALUES(provider_name),
|
||||
updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP(3)`,
|
||||
update.VIN, update.Protocol, update.SourceKey, update.SourceRef, update.ProviderName, update.Actor,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
}
|
||||
nextVersion := currentVersion + 1
|
||||
result, err := tx.ExecContext(ctx, `UPDATE platform_vehicle_source_policy_version
|
||||
@@ -121,25 +165,45 @@ WHERE vin = ? AND version = ?`, nextVersion, update.Actor, update.VIN, currentVe
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略更新冲突,请刷新后重试"}
|
||||
}
|
||||
summary := fmt.Sprintf(
|
||||
"%s:%s→%s,优先级 %d→%d",
|
||||
update.SourceLabel,
|
||||
enabledLabel(currentEnabled), enabledLabel(update.Enabled),
|
||||
currentPriority, update.Priority,
|
||||
)
|
||||
if update.Remark != "" {
|
||||
summary += ";" + update.Remark
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_policy_audit
|
||||
if policyChanged {
|
||||
summary := fmt.Sprintf(
|
||||
"%s:%s→%s,优先级 %d→%d",
|
||||
update.SourceLabel,
|
||||
enabledLabel(currentEnabled), enabledLabel(update.Enabled),
|
||||
currentPriority, update.Priority,
|
||||
)
|
||||
if update.Remark != "" {
|
||||
summary += ";" + update.Remark
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_policy_audit
|
||||
(vin, version, protocol, source_ref, source_key, source_label,
|
||||
old_enabled, new_enabled, old_priority, new_priority, old_remark, new_remark,
|
||||
actor, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey, update.SourceLabel,
|
||||
boolInt(currentEnabled), boolInt(update.Enabled), currentPriority, update.Priority, currentRemark, update.Remark,
|
||||
update.Actor, summary,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey, update.SourceLabel,
|
||||
boolInt(currentEnabled), boolInt(update.Enabled), currentPriority, update.Priority, currentRemark, update.Remark,
|
||||
update.Actor, summary,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
}
|
||||
if providerChanged {
|
||||
summary := fmt.Sprintf(
|
||||
"%s:提供方 %s→%s",
|
||||
update.SourceLabel,
|
||||
providerLabel(currentProvider), providerLabel(update.ProviderName),
|
||||
)
|
||||
if update.ProviderEvidence != "" {
|
||||
summary += ";核验依据:" + update.ProviderEvidence
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_provider_audit
|
||||
(vin, version, protocol, source_ref, source_key, old_provider, new_provider, actor, note, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey,
|
||||
currentProvider, update.ProviderName, update.Actor, update.ProviderEvidence, summary,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
@@ -160,3 +224,10 @@ func enabledLabel(value bool) string {
|
||||
}
|
||||
return "停用"
|
||||
}
|
||||
|
||||
func providerLabel(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "未维护"
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
@@ -18,8 +19,8 @@ func TestProductionVehicleSourcePolicyUsesStableEmptyAuditArray(t *testing.T) {
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(`SELECT version, updated_by`).WithArgs("VIN001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "updated_by", "updated_at"}))
|
||||
mock.ExpectQuery(`SELECT version, protocol, source_ref`).WithArgs("VIN001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"}))
|
||||
mock.ExpectQuery(`SELECT version, change_type, protocol, source_ref`).WithArgs("VIN001", "VIN001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "change_type", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"}))
|
||||
config, err := store.VehicleSourcePolicy(context.Background(), "VIN001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -41,3 +42,81 @@ func TestProductionVehicleSourcePolicyUsesStableEmptyAuditArray(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderUpdateKeepsPolicyRemarkSeparate(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
update := vehicleSourcePolicyStoreUpdate{
|
||||
VehicleSourcePolicyUpdate: VehicleSourcePolicyUpdate{
|
||||
Version: 2,
|
||||
SourceRef: "a-ref",
|
||||
ProviderName: "G7s",
|
||||
ProviderEvidence: "GPS 运维终端清单 2026-07-16",
|
||||
Enabled: true,
|
||||
Priority: 20,
|
||||
Remark: "保持当前优先级",
|
||||
Actor: "平台管理员",
|
||||
},
|
||||
VIN: "VIN001",
|
||||
Protocol: "JT808",
|
||||
SourceKey: "opaque-source-key",
|
||||
SourceLabel: "JT808 终端",
|
||||
CurrentEnabled: true,
|
||||
CurrentPriority: 20,
|
||||
CurrentRemark: "保持当前优先级",
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT IGNORE INTO platform_vehicle_source_policy_version
|
||||
(vin, version, updated_by) VALUES (?, 1, 'system')`)).
|
||||
WithArgs("VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM platform_vehicle_source_policy_version
|
||||
WHERE vin = ? FOR UPDATE`)).
|
||||
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow(2))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT enabled, priority, remark
|
||||
FROM vehicle_location_source_policy
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`)).
|
||||
WithArgs("VIN001", "JT808", "opaque-source-key").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"enabled", "priority", "remark"}).AddRow(1, 20, "保持当前优先级"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT provider_name
|
||||
FROM platform_vehicle_source_provider
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`)).
|
||||
WithArgs("VIN001", "JT808", "opaque-source-key").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"provider_name"}))
|
||||
mock.ExpectExec(`INSERT INTO platform_vehicle_source_provider`).
|
||||
WithArgs("VIN001", "JT808", "opaque-source-key", "a-ref", "G7s", "平台管理员").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(`UPDATE platform_vehicle_source_policy_version`).
|
||||
WithArgs(3, "平台管理员", "VIN001", 2).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO platform_vehicle_source_provider_audit`).
|
||||
WithArgs(
|
||||
"VIN001", 3, "JT808", "a-ref", "opaque-source-key",
|
||||
"", "G7s", "平台管理员", "GPS 运维终端清单 2026-07-16",
|
||||
"JT808 终端:提供方 未维护→G7s;核验依据:GPS 运维终端清单 2026-07-16",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectQuery(`SELECT version, updated_by`).
|
||||
WithArgs("VIN001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "updated_by", "updated_at"}).AddRow(3, "平台管理员", "2026-07-16 20:00:00"))
|
||||
mock.ExpectQuery(`SELECT version, change_type, protocol, source_ref`).
|
||||
WithArgs("VIN001", "VIN001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "change_type", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"}).
|
||||
AddRow(3, "provider", "JT808", "a-ref", "G7s", "平台管理员", "2026-07-16 20:00:00", "JT808 终端:提供方 未维护→G7s;核验依据:GPS 运维终端清单 2026-07-16"))
|
||||
|
||||
config, err := store.SaveVehicleSourcePolicy(context.Background(), update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Version != 3 || len(config.Audit) != 1 || config.Audit[0].ChangeType != "provider" {
|
||||
t.Fatalf("unexpected provider audit result: %+v", config)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,8 @@ export const api = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
version: update.version,
|
||||
providerName: update.providerName,
|
||||
providerEvidence: update.providerEvidence,
|
||||
enabled: update.enabled,
|
||||
priority: update.priority,
|
||||
remark: update.remark
|
||||
|
||||
@@ -546,6 +546,7 @@ export interface VehicleSourceEvidence {
|
||||
export interface VehicleLocationSourceEvidence {
|
||||
protocol: string;
|
||||
sourceLabel: string;
|
||||
providerOverride: string;
|
||||
terminalLabel: string;
|
||||
sourceKind: string;
|
||||
sourceRef?: string;
|
||||
@@ -553,6 +554,7 @@ export interface VehicleLocationSourceEvidence {
|
||||
recommended: boolean;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
policyRemark?: string;
|
||||
online: boolean;
|
||||
qualityStatus: string;
|
||||
qualityReason: string;
|
||||
@@ -597,6 +599,7 @@ export interface VehicleSourceEvidenceComparison {
|
||||
|
||||
export interface VehicleSourcePolicyAudit {
|
||||
version: number;
|
||||
changeType: 'policy' | 'provider';
|
||||
protocol: string;
|
||||
sourceRef: string;
|
||||
sourceLabel: string;
|
||||
@@ -624,6 +627,8 @@ export interface VehicleSourceDiagnostic {
|
||||
export interface VehicleSourcePolicyUpdate {
|
||||
version: number;
|
||||
sourceRef: string;
|
||||
providerName: string;
|
||||
providerEvidence: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
remark: string;
|
||||
|
||||
@@ -126,13 +126,13 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN001', plate: '粤A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 2, onlineSourceCount: 2, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 });
|
||||
mocks.vehicleSourceDiagnostic.mockResolvedValue({
|
||||
const diagnostic = {
|
||||
evidence: {
|
||||
vin: 'VIN001', plate: '粤A00001', mileageDate: '2026-07-16', recommendedLocationProtocol: 'JT808',
|
||||
recommendedLocationLabel: 'G7', locationConflict: true, conflictDistanceM: 233,
|
||||
locationSources: [{
|
||||
protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****0001', sourceKind: 'PLATFORM', sourceRef: 'a'.repeat(64),
|
||||
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true, qualityStatus: 'OK', qualityReason: '',
|
||||
protocol: 'JT808', sourceLabel: 'G7', providerOverride: '', terminalLabel: '终端 133****0001', sourceKind: 'PLATFORM', sourceRef: 'a'.repeat(64),
|
||||
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, policyRemark: '保持当前优先级', online: true, qualityStatus: 'OK', qualityReason: '',
|
||||
longitude: 113.1, latitude: 23.1, speedKmh: 30, totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00',
|
||||
receivedAt: '2026-07-16 10:00:01', firstSeenAt: '2026-03-01 08:00:00', reportIntervalSec: 10, reportSampleCount: 1000,
|
||||
selectionReason: '当前融合推荐'
|
||||
@@ -141,7 +141,9 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
},
|
||||
policy: { vin: 'VIN001', version: 1, updatedBy: 'system', updatedAt: '', audit: [] },
|
||||
recommendationReason: '当前推荐 G7', refreshHint: '下一次有效上报后重新选举'
|
||||
});
|
||||
};
|
||||
mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic);
|
||||
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
|
||||
@@ -154,5 +156,20 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
|
||||
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
|
||||
expect(screen.getByText('10s')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('G7 提供方'), { target: { value: 'G7s' } });
|
||||
const save = screen.getByRole('button', { name: '保存策略' });
|
||||
expect(save).toBeDisabled();
|
||||
expect(screen.getByLabelText('G7 策略备注')).toHaveValue('保持当前优先级');
|
||||
fireEvent.change(screen.getByLabelText('G7 提供方核验依据'), { target: { value: 'GPS 运维终端清单 2026-07-16' } });
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mocks.updateVehicleSourcePolicy).toHaveBeenCalledWith('VIN001', {
|
||||
version: 1,
|
||||
sourceRef: 'a'.repeat(64),
|
||||
providerName: 'G7s',
|
||||
providerEvidence: 'GPS 运维终端清单 2026-07-16',
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
remark: '保持当前优先级'
|
||||
}));
|
||||
await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal)));
|
||||
});
|
||||
|
||||
@@ -30,23 +30,31 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(source.enabled);
|
||||
const [priority, setPriority] = useState(source.priority);
|
||||
const [remark, setRemark] = useState('');
|
||||
const [providerName, setProviderName] = useState(source.providerOverride || '');
|
||||
const [providerEvidence, setProviderEvidence] = useState('');
|
||||
const [remark, setRemark] = useState(source.policyRemark || '');
|
||||
useEffect(() => {
|
||||
setEnabled(source.enabled);
|
||||
setPriority(source.priority);
|
||||
setRemark('');
|
||||
}, [source.enabled, source.priority, source.sourceRef]);
|
||||
setProviderName(source.providerOverride || '');
|
||||
setProviderEvidence('');
|
||||
setRemark(source.policyRemark || '');
|
||||
}, [source.enabled, source.priority, source.providerOverride, source.policyRemark, source.sourceRef]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleSourcePolicy(vin, {
|
||||
version: diagnostic.policy.version,
|
||||
sourceRef: source.sourceRef ?? '',
|
||||
providerName,
|
||||
providerEvidence,
|
||||
enabled,
|
||||
priority,
|
||||
remark
|
||||
}),
|
||||
onSuccess: onSaved
|
||||
});
|
||||
const changed = enabled !== source.enabled || priority !== source.priority || remark.trim() !== '';
|
||||
const providerChanged = providerName.trim() !== (source.providerOverride || '');
|
||||
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
|
||||
const changed = policyChanged || providerChanged;
|
||||
return <tr className={source.recommended ? 'is-recommended' : ''}>
|
||||
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
|
||||
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
|
||||
@@ -58,8 +66,10 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
<td className="v2-source-policy-cell">
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<input aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="调整原因" />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
|
||||
<input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={(event) => setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
|
||||
<input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={(event) => setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||||
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="启停或优先级调整原因(可选)" />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
|
||||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||||
</td>
|
||||
</tr>;
|
||||
@@ -127,10 +137,17 @@ function SourceDiagnosticWorkspace() {
|
||||
<div className="v2-source-recommendation"><strong>推荐说明</strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
|
||||
{!editable ? <p className="v2-source-readonly">当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</p> : null}
|
||||
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th>来源 / 终端</th><th>协议</th><th>在线 / 质量</th><th>首次 / 最近上报</th><th>上报周期</th><th>位置 / 里程</th><th>选举结论</th><th>运维策略</th></tr></thead><tbody>
|
||||
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next)} />)}
|
||||
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => {
|
||||
queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next);
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||||
]);
|
||||
}} />)}
|
||||
</tbody></table></div>
|
||||
<section className="v2-source-audit"><header><strong>最近策略审计</strong><span>仅记录实际变更,原始 source_key 不对前端暴露</span></header>
|
||||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p>该车辆尚无人工来源策略变更。</p>}
|
||||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p>该车辆尚无人工来源策略或提供方变更。</p>}
|
||||
</section>
|
||||
</> : null}
|
||||
</section>;
|
||||
|
||||
@@ -15,13 +15,13 @@ const evidence: VehicleSourceEvidence = {
|
||||
conflictDistanceM: 328,
|
||||
locationSources: [
|
||||
{
|
||||
protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM',
|
||||
protocol: 'JT808', sourceLabel: 'G7', providerOverride: '', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM',
|
||||
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true,
|
||||
qualityStatus: 'OK', qualityReason: '', longitude: 113.26, latitude: 23.13, speedKmh: 20,
|
||||
totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01'
|
||||
},
|
||||
{
|
||||
protocol: 'JT808', sourceLabel: '北斗平台', terminalLabel: '终端 139****1208', sourceKind: 'PLATFORM',
|
||||
protocol: 'JT808', sourceLabel: '北斗平台', providerOverride: '', terminalLabel: '终端 139****1208', sourceKind: 'PLATFORM',
|
||||
selectedWithinProtocol: false, recommended: false, enabled: true, priority: 30, online: true,
|
||||
qualityStatus: 'OK', qualityReason: '', longitude: 113.27, latitude: 23.14, speedKmh: 18,
|
||||
totalMileageKm: 998, eventTime: '2026-07-16 09:59:55', receivedAt: '2026-07-16 09:59:57'
|
||||
|
||||
@@ -416,7 +416,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.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; }
|
||||
.v2-source-table-wrap { margin: 0 14px 12px; overflow: auto; border: 1px solid #dfe7f0; border-radius: 8px; }.v2-source-table { width: 100%; min-width: 1320px; border-collapse: collapse; table-layout: fixed; }.v2-source-table th { height: 34px; background: #f6f8fb; color: #63748a; font-size: 8px; text-align: left; }.v2-source-table th, .v2-source-table td { border-bottom: 1px solid #e8edf3; padding: 7px 9px; vertical-align: top; }.v2-source-table tbody tr:last-child td { border-bottom: 0; }.v2-source-table tbody tr.is-recommended { background: #f4fbf8; }.v2-source-table td { color: #3e5067; font-size: 9px; line-height: 1.45; }.v2-source-table td > strong, .v2-source-table td > span { display: block; }.v2-source-table td > span { margin-top: 3px; color: var(--v2-muted); }.v2-source-table td > i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: #9aa7b8; }.v2-source-table td > i.is-online { background: var(--v2-green); }.v2-source-table td > i.is-offline { background: #94a3b8; }.v2-source-reason span { max-width: 230px; white-space: normal !important; }.v2-source-policy-cell { display: grid; grid-template-columns: auto 64px; gap: 5px; }.v2-source-policy-cell label { display: flex; align-items: center; gap: 4px; }.v2-source-policy-cell input[type=number], .v2-source-policy-cell input[type=text], .v2-source-policy-cell > input:not([type]) { min-width: 0; height: 26px; border: 1px solid #ccd7e5; border-radius: 5px; padding: 0 6px; font-size: 9px; }.v2-source-policy-cell > input:last-of-type { grid-column: 1 / -1; }.v2-source-policy-cell button { grid-column: 1 / -1; height: 27px; }.v2-source-policy-cell em { grid-column: 1 / -1; color: var(--v2-red); font-size: 8px; font-style: normal; }.v2-source-audit { margin: 0 14px 14px; border: 1px solid #e0e7f0; border-radius: 8px; }.v2-source-audit > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7edf4; padding: 0 10px; }.v2-source-audit > header strong { font-size: 10px; }.v2-source-audit > header span, .v2-source-audit > p { color: var(--v2-muted); font-size: 8px; }.v2-source-audit > p { margin: 0; padding: 12px; }.v2-source-audit ol { max-height: 170px; margin: 0; overflow: auto; padding: 0; list-style: none; }.v2-source-audit li { display: grid; min-height: 34px; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f6; padding: 5px 10px; font-size: 9px; }.v2-source-audit li em { color: var(--v2-muted); font-size: 8px; font-style: normal; }
|
||||
.v2-source-table-wrap { margin: 0 14px 12px; overflow: auto; border: 1px solid #dfe7f0; border-radius: 8px; }.v2-source-table { width: 100%; min-width: 1320px; border-collapse: collapse; table-layout: fixed; }.v2-source-table th { height: 34px; background: #f6f8fb; color: #63748a; font-size: 8px; text-align: left; }.v2-source-table th, .v2-source-table td { border-bottom: 1px solid #e8edf3; padding: 7px 9px; vertical-align: top; }.v2-source-table tbody tr:last-child td { border-bottom: 0; }.v2-source-table tbody tr.is-recommended { background: #f4fbf8; }.v2-source-table td { color: #3e5067; font-size: 9px; line-height: 1.45; }.v2-source-table td > strong, .v2-source-table td > span { display: block; }.v2-source-table td > span { margin-top: 3px; color: var(--v2-muted); }.v2-source-table td > i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: #9aa7b8; }.v2-source-table td > i.is-online { background: var(--v2-green); }.v2-source-table td > i.is-offline { background: #94a3b8; }.v2-source-reason span { max-width: 230px; white-space: normal !important; }.v2-source-policy-cell { display: grid; grid-template-columns: auto 64px; gap: 5px; }.v2-source-policy-cell label { display: flex; align-items: center; gap: 4px; }.v2-source-policy-cell input[type=number], .v2-source-policy-cell input[type=text], .v2-source-policy-cell > input:not([type]) { min-width: 0; height: 26px; border: 1px solid #ccd7e5; border-radius: 5px; padding: 0 6px; font-size: 9px; }.v2-source-policy-cell > .v2-source-provider-input, .v2-source-policy-cell > .v2-source-provider-evidence-input, .v2-source-policy-cell > .v2-source-policy-remark-input { grid-column: 1 / -1; }.v2-source-policy-cell button { grid-column: 1 / -1; height: 27px; }.v2-source-policy-cell em { grid-column: 1 / -1; color: var(--v2-red); font-size: 8px; font-style: normal; }.v2-source-audit { margin: 0 14px 14px; border: 1px solid #e0e7f0; border-radius: 8px; }.v2-source-audit > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7edf4; padding: 0 10px; }.v2-source-audit > header strong { font-size: 10px; }.v2-source-audit > header span, .v2-source-audit > p { color: var(--v2-muted); font-size: 8px; }.v2-source-audit > p { margin: 0; padding: 12px; }.v2-source-audit ol { max-height: 170px; margin: 0; overflow: auto; padding: 0; list-style: none; }.v2-source-audit li { display: grid; min-height: 34px; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f6; padding: 5px 10px; font-size: 9px; }.v2-source-audit li em { color: var(--v2-muted); font-size: 8px; font-style: normal; }
|
||||
|
||||
.v2-vehicle-search-page, .v2-not-found { display: grid; min-height: 100%; place-items: center; padding: 28px; }
|
||||
.v2-vehicle-search-card { width: min(660px, 100%); border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 54px; text-align: center; box-shadow: var(--v2-shadow); }
|
||||
|
||||
Reference in New Issue
Block a user