Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/reconciliation.go
2026-07-27 16:46:15 +08:00

400 lines
15 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) {
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.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
}
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 {
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) 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)
}