400 lines
15 KiB
Go
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)
|
|
}
|