feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
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 {
|
||||
@@ -31,10 +41,16 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
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
|
||||
}
|
||||
@@ -44,6 +60,256 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
@@ -69,6 +335,61 @@ func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, requ
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user