Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
485 lines
18 KiB
Go
485 lines
18 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ReconciliationExportFile struct {
|
|
Name string
|
|
Content []byte
|
|
RowCount int
|
|
}
|
|
|
|
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) {
|
|
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
|
return s.scopedReconciliationSummary(ctx)
|
|
}
|
|
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) {
|
|
query.AllowedVINs = principalVehicleVINScope(ctx)
|
|
store, err := s.reconciliationStore()
|
|
if err != nil {
|
|
return Page[ReconciliationIssue]{}, err
|
|
}
|
|
query.Keyword = strings.TrimSpace(query.Keyword)
|
|
query.Scope = strings.ToLower(strings.TrimSpace(query.Scope))
|
|
if query.Scope != "archived" {
|
|
query.Scope = "current"
|
|
}
|
|
query.RuleCode = strings.TrimSpace(query.RuleCode)
|
|
query.Category = strings.TrimSpace(query.Category)
|
|
query.Severity = strings.TrimSpace(query.Severity)
|
|
query.Status = strings.TrimSpace(query.Status)
|
|
query.Owner = strings.TrimSpace(query.Owner)
|
|
query.SLA = strings.TrimSpace(query.SLA)
|
|
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) ReconciliationAssignees(ctx context.Context, search string) ([]ReconciliationAssignee, error) {
|
|
store, err := s.reconciliationStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
search = strings.TrimSpace(search)
|
|
if len([]rune(search)) > 80 {
|
|
return nil, clientError{Code: "RECONCILIATION_ASSIGNEE_SEARCH_TOO_LONG", Message: "负责人搜索不能超过 80 个字符"}
|
|
}
|
|
items, err := store.ReconciliationAssignees(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byName := make(map[string]ReconciliationAssignee, len(items)+1)
|
|
for _, item := range items {
|
|
item.Name = strings.TrimSpace(item.Name)
|
|
if item.Name != "" {
|
|
byName[strings.ToLower(item.Name)] = item
|
|
}
|
|
}
|
|
if principal, ok := PrincipalFromContext(ctx); ok {
|
|
name := strings.TrimSpace(principal.Name)
|
|
if name == "" {
|
|
name = strings.TrimSpace(principal.Username)
|
|
}
|
|
if name != "" {
|
|
key := strings.ToLower(name)
|
|
item, exists := byName[key]
|
|
if !exists {
|
|
item = ReconciliationAssignee{Name: name, Source: "current"}
|
|
}
|
|
if item.Username == "" {
|
|
item.Username = strings.TrimSpace(principal.Username)
|
|
}
|
|
item.Current = true
|
|
byName[key] = item
|
|
}
|
|
}
|
|
needle := strings.ToLower(search)
|
|
result := make([]ReconciliationAssignee, 0, len(byName))
|
|
for _, item := range byName {
|
|
if needle != "" && !strings.Contains(strings.ToLower(item.Name+" "+item.Username), needle) {
|
|
continue
|
|
}
|
|
result = append(result, item)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Current != result[j].Current {
|
|
return result[i].Current
|
|
}
|
|
if result[i].ActiveCount != result[j].ActiveCount {
|
|
return result[i].ActiveCount > result[j].ActiveCount
|
|
}
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
if len(result) > 50 {
|
|
result = result[:50]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) ExportReconciliationIssues(ctx context.Context, query ReconciliationQuery) (ReconciliationExportFile, error) {
|
|
query.Offset = 0
|
|
query.Limit = 200
|
|
first, err := s.ReconciliationIssues(ctx, query)
|
|
if err != nil {
|
|
return ReconciliationExportFile{}, err
|
|
}
|
|
if first.Total > reconciliationFindingLimit {
|
|
return ReconciliationExportFile{}, clientError{Code: "RECONCILIATION_EXPORT_TOO_LARGE", Message: "当前筛选超过 50,000 条,请缩小范围后导出"}
|
|
}
|
|
items := append([]ReconciliationIssue(nil), first.Items...)
|
|
for offset := len(first.Items); offset < first.Total; offset += query.Limit {
|
|
query.Offset = offset
|
|
page, pageErr := s.ReconciliationIssues(ctx, query)
|
|
if pageErr != nil {
|
|
return ReconciliationExportFile{}, pageErr
|
|
}
|
|
items = append(items, page.Items...)
|
|
if len(page.Items) == 0 {
|
|
break
|
|
}
|
|
}
|
|
var buffer bytes.Buffer
|
|
buffer.WriteString("\xEF\xBB\xBF")
|
|
writer := csv.NewWriter(&buffer)
|
|
_ = writer.Write([]string{"差异编号", "等级", "状态", "规则", "问题", "VIN", "车牌", "来源 A", "来源 B", "负责人", "处理期限", "首次发现", "最近发现", "命中次数", "结论说明", "处置人", "归档时间", "归档人", "归档原因", "版本"})
|
|
for _, item := range items {
|
|
_ = writer.Write([]string{
|
|
reconciliationCSVCell(item.ID), reconciliationCSVCell(reconciliationSeverityLabel(item.Severity)), reconciliationCSVCell(reconciliationStatusLabel(item.Status)),
|
|
reconciliationCSVCell(item.RuleCode), reconciliationCSVCell(item.Title), reconciliationCSVCell(item.VIN), reconciliationCSVCell(item.Plate),
|
|
reconciliationCSVCell(item.ProtocolA), reconciliationCSVCell(item.ProtocolB), reconciliationCSVCell(item.Assignee), reconciliationCSVCell(item.DueAt),
|
|
reconciliationCSVCell(item.FirstSeenAt), reconciliationCSVCell(item.LastSeenAt), fmt.Sprintf("%d", item.OccurrenceCount),
|
|
reconciliationCSVCell(item.ResolutionNote), reconciliationCSVCell(item.ResolvedBy),
|
|
reconciliationCSVCell(item.ArchivedAt), reconciliationCSVCell(item.ArchivedBy), reconciliationCSVCell(item.ArchiveReason),
|
|
fmt.Sprintf("%d", item.Version),
|
|
})
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return ReconciliationExportFile{}, err
|
|
}
|
|
return ReconciliationExportFile{
|
|
Name: fmt.Sprintf("质量差异_%s.csv", time.Now().Format("20060102_150405")),
|
|
Content: buffer.Bytes(),
|
|
RowCount: len(items),
|
|
}, nil
|
|
}
|
|
|
|
func reconciliationCSVCell(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" && strings.ContainsRune("=+-@", rune(value[0])) {
|
|
return "'" + value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func reconciliationSeverityLabel(value string) string {
|
|
if label := map[string]string{"critical": "严重", "major": "重要", "minor": "一般"}[value]; label != "" {
|
|
return label
|
|
}
|
|
return value
|
|
}
|
|
|
|
func reconciliationStatusLabel(value string) string {
|
|
if label := map[string]string{"pending": "待处理", "confirmed_source_a": "确认来源 A", "confirmed_source_b": "确认来源 B", "no_action": "无需处理", "fixed": "已修复", "recovered": "已恢复"}[value]; label != "" {
|
|
return label
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeReconciliationLifecycleRequest(ctx context.Context, request ReconciliationLifecycleRequest) (ReconciliationLifecycleRequest, error) {
|
|
request.Reason = strings.TrimSpace(request.Reason)
|
|
if request.Version <= 0 {
|
|
return request, clientError{Code: "RECONCILIATION_VERSION_REQUIRED", Message: "差异记录版本不能为空"}
|
|
}
|
|
if len([]rune(request.Reason)) < 4 {
|
|
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
|
}
|
|
if len([]rune(request.Reason)) > 500 {
|
|
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 500 字"}
|
|
}
|
|
if err := authorizeInternalOperations(ctx, true); err != nil {
|
|
return request, clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以整理差异审计归档"}
|
|
}
|
|
request.Actor = ActorFromContext(ctx)
|
|
return request, nil
|
|
}
|
|
|
|
func (s *Service) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || len(id) > 64 {
|
|
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
|
}
|
|
normalized, err := normalizeReconciliationLifecycleRequest(ctx, request)
|
|
if err != nil {
|
|
return ReconciliationIssue{}, err
|
|
}
|
|
store, err := s.reconciliationStore()
|
|
if err != nil {
|
|
return ReconciliationIssue{}, err
|
|
}
|
|
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
|
if _, err := s.ReconciliationIssue(ctx, id); err != nil {
|
|
return ReconciliationIssue{}, err
|
|
}
|
|
}
|
|
return store.SetReconciliationIssueArchived(ctx, id, archived, normalized)
|
|
}
|
|
|
|
func (s *Service) BatchSetReconciliationIssuesArchived(ctx context.Context, archived bool, request ReconciliationBatchLifecycleRequest) (ReconciliationBatchActionResult, error) {
|
|
result := ReconciliationBatchActionResult{
|
|
Requested: len(request.Items),
|
|
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
|
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
|
}
|
|
if len(request.Items) == 0 {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
|
}
|
|
if len(request.Items) > 20 {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多整理 20 条差异记录"}
|
|
}
|
|
seen := make(map[string]struct{}, len(request.Items))
|
|
for _, item := range request.Items {
|
|
id := strings.TrimSpace(item.ID)
|
|
if id == "" || len(id) > 64 {
|
|
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量整理中包含无效的差异记录编号"}
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量整理不能重复选择同一条差异记录"}
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
for _, item := range request.Items {
|
|
updated, err := s.SetReconciliationIssueArchived(ctx, item.ID, archived, ReconciliationLifecycleRequest{
|
|
Version: item.Version,
|
|
Reason: request.Reason,
|
|
})
|
|
if err == nil {
|
|
result.Succeeded = append(result.Succeeded, updated)
|
|
continue
|
|
}
|
|
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "整理失败,请刷新后重试"}
|
|
if itemError, ok := asClientError(err); ok {
|
|
failure.Code, failure.Message = itemError.Code, itemError.Message
|
|
}
|
|
result.Skipped = append(result.Skipped, failure)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (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
|
|
}
|
|
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
|
if _, err := s.ReconciliationIssue(ctx, id); err != nil {
|
|
return ReconciliationIssue{}, err
|
|
}
|
|
}
|
|
return store.AssignReconciliationIssue(ctx, id, request)
|
|
}
|
|
|
|
func (s *Service) BatchAssignReconciliationIssues(ctx context.Context, request ReconciliationBatchAssignmentRequest) (ReconciliationBatchActionResult, error) {
|
|
result := ReconciliationBatchActionResult{Requested: len(request.Items), Succeeded: []ReconciliationIssue{}, Skipped: []ReconciliationBatchActionFailure{}}
|
|
if len(request.Items) == 0 {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
|
}
|
|
if len(request.Items) > 20 {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多交接 20 条差异记录"}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, item := range request.Items {
|
|
id := strings.TrimSpace(item.ID)
|
|
if id == "" || len(id) > 64 || seen[id] {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_ITEM_INVALID", Message: "批量交接包含无效或重复记录"}
|
|
}
|
|
seen[id] = true
|
|
}
|
|
for _, item := range request.Items {
|
|
updated, err := s.AssignReconciliationIssue(ctx, item.ID, ReconciliationAssignmentRequest{Version: item.Version, Assignee: request.Assignee, DueAt: request.DueAt})
|
|
if err == nil {
|
|
result.Succeeded = append(result.Succeeded, updated)
|
|
continue
|
|
}
|
|
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "交接失败,请刷新后重试"}
|
|
if itemError, ok := asClientError(err); ok {
|
|
failure.Code, failure.Message = itemError.Code, itemError.Message
|
|
}
|
|
result.Skipped = append(result.Skipped, failure)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
item, err := store.ReconciliationIssue(ctx, id)
|
|
if err != nil {
|
|
return item, err
|
|
}
|
|
if err = authorizeVehicleVIN(ctx, item.VIN); err != nil {
|
|
return ReconciliationIssue{}, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
|
if _, err := s.ReconciliationIssue(ctx, id); err != nil {
|
|
return ReconciliationIssue{}, err
|
|
}
|
|
}
|
|
return store.UpdateReconciliationIssue(ctx, id, request)
|
|
}
|
|
|
|
func (s *Service) BatchUpdateReconciliationIssues(ctx context.Context, request ReconciliationBatchActionRequest) (ReconciliationBatchActionResult, error) {
|
|
request.Status = strings.TrimSpace(request.Status)
|
|
request.Note = strings.TrimSpace(request.Note)
|
|
result := ReconciliationBatchActionResult{
|
|
Requested: len(request.Items),
|
|
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
|
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
|
}
|
|
if len(request.Items) == 0 {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
|
}
|
|
if len(request.Items) > 20 {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多处置 20 条差异记录"}
|
|
}
|
|
allowed := map[string]bool{"pending": true, "no_action": true, "fixed": true}
|
|
if !allowed[request.Status] {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_STATUS_INVALID", Message: "批量处置仅支持退回待复核、无需处理或已修复"}
|
|
}
|
|
if request.Status != "pending" && request.Note == "" {
|
|
return result, clientError{Code: "RECONCILIATION_NOTE_REQUIRED", Message: "批量处置必须填写说明"}
|
|
}
|
|
if len([]rune(request.Note)) > 500 {
|
|
return result, clientError{Code: "RECONCILIATION_NOTE_TOO_LONG", Message: "处置说明不能超过 500 个字符"}
|
|
}
|
|
seen := make(map[string]struct{}, len(request.Items))
|
|
for _, item := range request.Items {
|
|
id := strings.TrimSpace(item.ID)
|
|
if id == "" || len(id) > 64 {
|
|
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量处置中包含无效的差异记录编号"}
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量处置不能重复选择同一条差异记录"}
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
for _, item := range request.Items {
|
|
updated, err := s.UpdateReconciliationIssue(ctx, strings.TrimSpace(item.ID), ReconciliationActionRequest{
|
|
Version: item.Version,
|
|
Status: request.Status,
|
|
Note: request.Note,
|
|
})
|
|
if err == nil {
|
|
result.Succeeded = append(result.Succeeded, updated)
|
|
continue
|
|
}
|
|
failure := ReconciliationBatchActionFailure{ID: strings.TrimSpace(item.ID), Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "处置失败,请刷新后重试"}
|
|
if itemError, ok := asClientError(err); ok {
|
|
failure.Code = itemError.Code
|
|
failure.Message = itemError.Message
|
|
}
|
|
result.Skipped = append(result.Skipped, failure)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
|
|
store, err := s.reconciliationStore()
|
|
if err != nil {
|
|
return ReconciliationEvaluationResult{}, err
|
|
}
|
|
return store.EvaluateReconciliation(ctx)
|
|
}
|
|
|
|
// Historical run totals have no VIN dimension. Scoped users receive current
|
|
// issue totals only, rather than another user's fleet-wide trend.
|
|
func (s *Service) scopedReconciliationSummary(ctx context.Context) (ReconciliationSummary, error) {
|
|
if store, ok := s.store.(interface {
|
|
ScopedReconciliationSummary(context.Context) (ReconciliationSummary, error)
|
|
}); ok {
|
|
return store.ScopedReconciliationSummary(ctx)
|
|
}
|
|
out := ReconciliationSummary{ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{}, Trend: []ReconciliationTrendPoint{}, AsOf: time.Now().Format(time.RFC3339)}
|
|
rules, severities := map[string]int{}, map[string]int{}
|
|
for _, scope := range []string{"current", "archived"} {
|
|
for offset := 0; ; offset += 200 {
|
|
page, err := s.ReconciliationIssues(ctx, ReconciliationQuery{Scope: scope, Limit: 200, Offset: offset})
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if page.Total > reconciliationFindingLimit {
|
|
return out, clientError{Code: "SCOPE_TOO_LARGE", Message: "授权范围内差异过多,请缩小查询范围"}
|
|
}
|
|
for _, item := range page.Items {
|
|
if scope == "archived" {
|
|
out.Archived++
|
|
continue
|
|
}
|
|
out.Current++
|
|
active := item.Status == "pending" || item.Status == "confirmed_source_a" || item.Status == "confirmed_source_b"
|
|
switch item.Status {
|
|
case "pending":
|
|
out.Pending++
|
|
case "confirmed_source_a", "confirmed_source_b":
|
|
out.Confirmed++
|
|
case "recovered":
|
|
out.Recovered++
|
|
}
|
|
if active {
|
|
out.Active++
|
|
rules[item.RuleCode]++
|
|
severities[item.Severity]++
|
|
if due, ok := parseTrackRequestTime(item.DueAt); ok && due.Before(time.Now()) {
|
|
out.OverSLA++
|
|
}
|
|
}
|
|
}
|
|
if offset+len(page.Items) >= page.Total || len(page.Items) == 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for name, count := range rules {
|
|
out.ByRule = append(out.ByRule, ReconciliationBucket{Name: name, Count: count})
|
|
}
|
|
for name, count := range severities {
|
|
out.BySeverity = append(out.BySeverity, ReconciliationBucket{Name: name, Count: count})
|
|
}
|
|
sort.Slice(out.ByRule, func(i, j int) bool { return out.ByRule[i].Name < out.ByRule[j].Name })
|
|
sort.Slice(out.BySeverity, func(i, j int) bool { return out.BySeverity[i].Name < out.BySeverity[j].Name })
|
|
return out, nil
|
|
}
|