package webui import ( "context" "encoding/json" "errors" "strings" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" ) const localAdminSubject = "local-admin" type UsecaseAdapter struct { tasks *usecase.TaskService assets *usecase.AssetService authorizations *usecase.OrderAuthorizationService freight *usecase.FreightService procurement *usecase.ProcurementService } func (adapter *UsecaseAdapter) SetProcurement( procurement *usecase.ProcurementService, ) { adapter.procurement = procurement } func NewUsecaseAdapter( tasks *usecase.TaskService, assets *usecase.AssetService, authorizations *usecase.OrderAuthorizationService, freight ...*usecase.FreightService, ) (*UsecaseAdapter, error) { if tasks == nil || assets == nil || authorizations == nil { return nil, errors.New("admin web use cases are required") } adapter := &UsecaseAdapter{ tasks: tasks, assets: assets, authorizations: authorizations, } if len(freight) > 0 { adapter.freight = freight[0] } return adapter, nil } func (adapter *UsecaseAdapter) ListFreightOrders( ctx context.Context, limit int, ) ([]FreightOrder, error) { if adapter.freight == nil { return nil, ErrUnavailable } orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit) if err != nil { return nil, mapUsecaseError(err) } result := make([]FreightOrder, 0, len(orders)) for _, order := range orders { result = append(result, freightOrderFrom(order)) } return result, nil } func (adapter *UsecaseAdapter) GetFreightOrder( ctx context.Context, orderID string, ) (FreightOrderDetail, error) { if adapter.freight == nil { return FreightOrderDetail{}, ErrUnavailable } detail, err := adapter.freight.GetOrder( ctx, localAdminSubject, orderID, ) if err != nil { return FreightOrderDetail{}, mapUsecaseError(err) } requestByItem := map[string]domain.ProcurementRequest{} if adapter.procurement != nil { requests, requestErr := adapter.procurement.ListForOrder( ctx, localAdminSubject, detail.Order.ID, ) if requestErr != nil { return FreightOrderDetail{}, mapUsecaseError(requestErr) } for _, request := range requests { if _, exists := requestByItem[request.FreightOrderItemID]; !exists { requestByItem[request.FreightOrderItemID] = request } } } items := make([]FreightItemReview, 0, len(detail.Items)) for _, item := range detail.Items { view := FreightOrderItem{ ID: item.ID, ExternalItemID: item.ExternalItemID, Title: item.Title, ProductSpec: item.ProductSpec, SKU: item.SKU, ProductThumbRef: stringValue(item.ProductThumbRef), PurchaseStatus: stringValue(item.PurchaseStatus), Revision: item.Revision, } if item.Quantity != nil { view.Quantity = *item.Quantity } review := FreightItemReview{Item: view} if request, exists := requestByItem[item.ID]; exists { requestView := procurementRequestFrom(request) review.Request = &requestView } items = append(items, review) } return FreightOrderDetail{ Order: freightOrderFrom(detail.Order), Items: items, }, nil } func (adapter *UsecaseAdapter) CreateProcurementRequest( ctx context.Context, input CreateProcurementRequestInput, ) (ProcurementRequest, error) { if adapter.procurement == nil { return ProcurementRequest{}, ErrUnavailable } result, err := adapter.procurement.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: localAdminSubject, ActorUserID: input.ActorUserID, FreightOrderItemID: input.FreightOrderItemID, ConfirmProcurementNeeded: input.ConfirmProcurementNeeded, }, ) if err != nil { return ProcurementRequest{}, mapUsecaseError(err) } return procurementRequestFrom(result.Request), nil } func (adapter *UsecaseAdapter) BindProcurementReference( ctx context.Context, input BindProcurementReferenceInput, ) (ProcurementRequest, error) { if adapter.procurement == nil { return ProcurementRequest{}, ErrUnavailable } request, err := adapter.procurement.BindReference( ctx, usecase.BindProcurementReferenceCommand{ CreatorSubject: localAdminSubject, ActorUserID: input.ActorUserID, RequestID: input.RequestID, ImageAssetID: input.ImageAssetID, }, ) if err != nil { return ProcurementRequest{}, mapUsecaseError(err) } return procurementRequestFrom(request), nil } func (adapter *UsecaseAdapter) CreateProcurementTask( ctx context.Context, input CreateProcurementTaskInput, ) (Task, error) { if adapter.procurement == nil { return Task{}, ErrUnavailable } result, err := adapter.procurement.CreateTask( ctx, usecase.CreateProcurementTaskCommand{ CreatorSubject: localAdminSubject, ActorUserID: input.ActorUserID, RequestID: input.RequestID, IdempotencyKey: input.IdempotencyKey, }, ) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromPurchase(result.Task), nil } func procurementRequestFrom( request domain.ProcurementRequest, ) ProcurementRequest { result := ProcurementRequest{ ID: request.ID, FreightOrderItemID: request.FreightOrderItemID, SourceRevision: request.SourceRevision, Status: string(request.Status), StatusLabel: procurementStatusLabel(request.Status), BlockingCode: stringValue(request.BlockingCode), BlockingLabel: procurementBlockingLabel( stringValue(request.BlockingCode), ), ReferenceAssetID: stringValue(request.ReferenceAssetID), PurchaseTaskID: stringValue(request.PurchaseTaskID), SourceChanged: request.SourceChanged, } if request.SourceChanged { result.StatusLabel = "来源已变化" } return result } func procurementStatusLabel( status domain.ProcurementRequestStatus, ) string { switch status { case domain.ProcurementBlocked: return "资料阻塞" case domain.ProcurementNeedsImage: return "需要参考图" case domain.ProcurementReady: return "可以生成任务" case domain.ProcurementTaskCreated: return "任务已生成" case domain.ProcurementSourceChanged: return "来源已变化" default: return "未知状态" } } func procurementBlockingLabel(code string) string { switch code { case domain.ProcurementBlockSourceCanceled: return "货运单已取消" case domain.ProcurementBlockTitleRequired: return "商品标题缺失或超限" case domain.ProcurementBlockSKURequired: return "SKU 缺失或超限" case domain.ProcurementBlockQuantityRequired: return "数量缺失或无效" case domain.ProcurementBlockSourceChanged: return "ERP 来源已更新" default: return "" } } func (adapter *UsecaseAdapter) GetFreightSync( ctx context.Context, syncID string, ) (FreightSync, error) { if adapter.freight == nil { return FreightSync{}, ErrUnavailable } run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID) if err != nil { return FreightSync{}, mapUsecaseError(err) } return freightSyncFrom(run), nil } func (adapter *UsecaseAdapter) CreateFreightSync( ctx context.Context, input CreateFreightSyncInput, ) (FreightSync, error) { if adapter.freight == nil { return FreightSync{}, ErrUnavailable } result, err := adapter.freight.CreateOrderSync( ctx, usecase.CreateFreightSyncCommand{ CreatorSubject: localAdminSubject, ActorUserID: input.ActorUserID, IdempotencyKey: input.IdempotencyKey, OrderNumber: input.OrderNumber, }, ) if err != nil { return FreightSync{}, mapUsecaseError(err) } return freightSyncFrom(result.Run), nil } func freightOrderFrom(order domain.FreightOrder) FreightOrder { result := FreightOrder{ ID: order.ID, ExternalStockID: order.ExternalStockID, SourceCode: order.SourceCode, ShopName: stringValue(order.ShopName), OrderStatus: stringValue(order.OrderStatus), PurchaseStatus: stringValue(order.PurchaseStatus), ItemCount: order.ItemCount, Revision: order.Revision, UpdatedAt: order.UpdatedAt, } if order.SourceCreatedAt != nil { result.SourceCreatedAt = *order.SourceCreatedAt } if order.IsCanceled != nil { result.IsCanceled = *order.IsCanceled } return result } func freightSyncFrom(run domain.FreightSyncRun) FreightSync { result := FreightSync{ ID: run.ID, Status: string(run.Status), ErrorCode: stringValue(run.ErrorCode), OrderCount: run.OrderCount, ItemCount: run.ItemCount, CreatedAt: run.CreatedAt, } if run.FinishedAt != nil { result.FinishedAt = *run.FinishedAt } return result } func (adapter *UsecaseAdapter) ListTasks( ctx context.Context, input ListTasksInput, ) (TaskList, error) { var status *string if input.Status != "" { value := input.Status status = &value } page, err := adapter.tasks.List(ctx, usecase.ListTasksQuery{ CreatorSubject: localAdminSubject, Status: status, Query: input.Query, Limit: input.Limit, Cursor: input.Cursor, }) if err != nil { return TaskList{}, mapUsecaseError(err) } items := make([]TaskSummary, 0, len(page.Items)) for _, item := range page.Items { items = append(items, taskSummaryFrom(item)) } return TaskList{ Items: items, NextCursor: page.NextCursor, }, nil } func (adapter *UsecaseAdapter) GetTask( ctx context.Context, taskID string, ) (Task, error) { detail, err := adapter.tasks.Get(ctx, localAdminSubject, taskID) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromDetail(detail), nil } func (adapter *UsecaseAdapter) UploadReference( ctx context.Context, input UploadReferenceInput, ) (UploadedAsset, error) { result, err := adapter.assets.UploadTaskReference( ctx, usecase.UploadTaskReferenceCommand{ CreatorSubject: localAdminSubject, IdempotencyKey: input.IdempotencyKey, DeclaredMediaType: input.DeclaredType, Content: input.Content, }, ) if err != nil { return UploadedAsset{}, mapUsecaseError(err) } return UploadedAsset{ ID: result.Asset.ID, }, nil } func (adapter *UsecaseAdapter) CreateTask( ctx context.Context, input CreateTaskInput, ) (Task, error) { maxInt := int64(^uint(0) >> 1) if input.Quantity > maxInt { return Task{}, ErrValidation } var budget *string if input.MaxBudget != "" { value := input.MaxBudget budget = &value } result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{ CreatorSubject: localAdminSubject, ActorUserID: actorUserID(ctx), IdempotencyKey: input.IdempotencyKey, Title: input.Title, Description: input.Description, SKU: input.SKU, ImageAssetID: input.ImageAssetID, Quantity: int(input.Quantity), MaxBudget: budget, }) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromPurchase(result.Task), nil } func actorUserID(ctx context.Context) string { principal, ok := authcommon.Principal(ctx) if !ok || principal.Role != domain.UserRoleAdmin { return "" } return principal.UserID } func (adapter *UsecaseAdapter) CancelTask( ctx context.Context, input CancelTaskInput, ) (Task, error) { task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{ CreatorSubject: localAdminSubject, ActorUserID: actorUserID(ctx), TaskID: input.TaskID, Reason: "管理员取消", }) if err != nil { return Task{}, mapUsecaseError(err) } return taskFromPurchase(task), nil } func (adapter *UsecaseAdapter) AuthorizeOrder( ctx context.Context, input AuthorizeOrderInput, ) (OrderAuthorization, error) { detail, err := adapter.tasks.Get(ctx, localAdminSubject, input.TaskID) if err != nil { return OrderAuthorization{}, mapUsecaseError(err) } if detail.Task.Version != input.ExpectedTaskVersion || detail.Execution == nil || detail.Report == nil || detail.Report.DecisionDataset == nil { return OrderAuthorization{}, ErrConflict } var supersedes *string if input.SupersedesAuthorizationID != "" { value := input.SupersedesAuthorizationID supersedes = &value } items := make( []usecase.OrderAuthorizationItemInput, 0, len(detail.Report.DecisionDataset.Observations), ) for _, observation := range detail.Report.DecisionDataset.Observations { if observation.Identity == nil { return OrderAuthorization{}, ErrConflict } label := "REJECT" reason := input.RejectedReasonCode if observation.Identity.CandidateKey == input.CandidateKey { label = "ACCEPT" reason = input.SelectedReasonCode } items = append(items, usecase.OrderAuthorizationItemInput{ CandidateKey: observation.Identity.CandidateKey, Label: label, PrimaryReasonCode: reason, ReasonCodes: []string{reason}, }) } result, err := adapter.authorizations.Create( ctx, usecase.CreateOrderAuthorizationCommand{ ActorUserID: actorUserID(ctx), TaskID: input.TaskID, IdempotencyKey: input.IdempotencyKey, ExecutionID: detail.Execution.ID, TaskContentSHA256: usecase.TaskContentSHA256(detail.Task), ExpectedTaskVersion: input.ExpectedTaskVersion, CandidateKey: input.CandidateKey, ReasonSchemaVersion: 1, PrimaryReasonCode: "SELECTED_BEST_MATCH", Note: input.Note, SupersedesAuthorizationID: supersedes, Items: items, }, ) if err != nil { return OrderAuthorization{}, mapUsecaseError(err) } return orderAuthorizationFrom(result.Authorization), nil } func taskSummaryFrom(task domain.PurchaseTask) TaskSummary { return TaskSummary{ ID: task.ID, Title: task.Title, SKU: task.SKU, Status: string(task.Status), UpdatedAt: task.UpdatedAt, } } func taskFromPurchase(task domain.PurchaseTask) Task { budget := domain.FormatOptionalCNY(task.MaxBudgetCents) result := Task{ ID: task.ID, Title: task.Title, SKU: task.SKU, Description: task.Description, Quantity: int64(task.Quantity), Status: string(task.Status), ReferenceAssetID: task.ImageAssetID, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, } if budget != nil { result.MaxBudget = *budget } return result } func taskFromDetail(detail domain.TaskDetail) Task { task := taskFromPurchase(detail.Task) task.Version = detail.Task.Version task.ReferenceAssetID = detail.Asset.ID task.ExecutionReport = executionReportFrom(detail.Report) task.TaskContentSHA256 = usecase.TaskContentSHA256(detail.Task) if detail.Execution != nil { task.ExecutionID = detail.Execution.ID } if detail.Report != nil && detail.Report.DecisionDataset != nil { for _, observation := range detail.Report.DecisionDataset.Observations { if observation.Identity == nil { continue } candidate := AuthorizationCandidate{ CandidateKey: observation.Identity.CandidateKey, Ordinal: observation.Ordinal, Title: observation.Title, SKUText: observation.SKUText, PriceText: observation.PriceText, EvidenceURLs: make([]string, 0, len(observation.EvidenceAssetIDs)), } for _, evidenceID := range observation.EvidenceAssetIDs { candidate.EvidenceURLs = append( candidate.EvidenceURLs, "/api/v1/tasks/"+detail.Task.ID+ "/evidence/"+evidenceID+"/content", ) } task.Candidates = append(task.Candidates, candidate) } } for _, authorization := range detail.OrderAuthorizations { task.OrderAuthorizations = append( task.OrderAuthorizations, orderAuthorizationFrom(authorization), ) } for _, submission := range detail.OrderSubmissions { task.OrderSubmissions = append( task.OrderSubmissions, orderSubmissionFrom(submission), ) } return task } func orderSubmissionFrom( submission domain.OrderSubmission, ) OrderSubmission { unitPrice := submission.ExpectedUnitPriceCents totalPrice := submission.ExpectedTotalPriceCents result := OrderSubmission{ ID: submission.ID, Status: string(submission.Status), StatusLabel: orderSubmissionStatusLabel(submission.Status), ExpectedTitle: submission.ExpectedTitle, ExpectedSKU: submission.ExpectedSKU, ExpectedQuantity: submission.ExpectedQuantity, ExpectedUnitPrice: *domain.FormatOptionalCNY(&unitPrice), ExpectedTotalPrice: *domain.FormatOptionalCNY(&totalPrice), FencedAt: submission.FencedAt, } if submission.PlatformOrderNo != nil { result.PlatformOrderNo = *submission.PlatformOrderNo } if submission.PlatformOrderedAt != nil { result.PlatformOrderedAt = *submission.PlatformOrderedAt } if submission.PlatformOrderStatus != nil { result.PlatformOrderStatus = *submission.PlatformOrderStatus } if submission.ReconciliationEvidenceAssetID != nil { result.EvidenceContentURL = "/api/v1/tasks/" + submission.TaskID + "/evidence/" + *submission.ReconciliationEvidenceAssetID + "/content" } if submission.ManualReasonCode != nil { result.ManualReasonLabel = orderSubmissionManualReasonLabel( *submission.ManualReasonCode, ) } if submission.ReconciledAt != nil { result.ReconciledAt = *submission.ReconciledAt } if submission.ManualReviewAt != nil { result.ManualReviewAt = *submission.ManualReviewAt } return result } func orderSubmissionStatusLabel(status domain.OrderSubmissionStatus) string { switch status { case domain.OrderSubmissionFenced: return "正在对账" case domain.OrderSubmissionManualReview: return "需要人工对账" case domain.OrderSubmissionReconciled: return "待人工确认付款" default: return "未知状态" } } func orderSubmissionManualReasonLabel(code string) string { switch code { case "ORDER_NOT_FOUND": return "未找到符合条件的新订单" case "ORDER_AMBIGUOUS": return "找到多个可能订单" case "ORDER_FIELDS_INCOMPLETE": return "订单字段不完整" case "ORDER_PAGE_UNKNOWN": return "订单页面无法确认" case "RISK_OR_PAYMENT_BOUNDARY": return "遇到风控或付款边界" case "EVIDENCE_UNAVAILABLE": return "无法取得对账证据" default: return "订单需要人工核对" } } func orderAuthorizationFrom( authorization domain.OrderAuthorization, ) OrderAuthorization { result := OrderAuthorization{ ID: authorization.ID, Version: authorization.AuthorizationVersion, CandidateKey: authorization.CandidateKey, CandidateSKUText: authorization.CandidateSKUText, CandidatePriceText: authorization.CandidatePriceText, Quantity: authorization.Quantity, Status: string(authorization.Status), CreatedAt: authorization.CreatedAt, } if authorization.SupersedesAuthorizationID != nil { result.SupersedesID = *authorization.SupersedesAuthorizationID } return result } func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport { if report == nil { return nil } result := &ExecutionReport{ Events: make([]ExecutionReportEvent, 0, len(report.Events)), Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)), } for _, event := range report.Events { result.Events = append(result.Events, ExecutionReportEvent{ Step: event.Step, Type: event.Type, Message: event.Message, OccurredAt: event.OccurredAt, ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry, }) } for _, evidence := range report.EvidenceAssets { result.Evidence = append(result.Evidence, ExecutionReportEvidence{ ID: evidence.ID, ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content", SHA256: evidence.SHA256, SizeBytes: evidence.SizeBytes, CreatedAt: evidence.CreatedAt, ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry, }) } if batch := report.CandidateBatch; batch != nil { result.Mode = batch.ExecutionMode result.SearchQuery = batch.SearchQuery result.Provenance = prettyAuditJSON(batch.ProvenanceJSON) result.Candidates = prettyAuditJSON(&batch.CandidatesJSON) result.Recommendation = prettyAuditJSON(batch.RecommendationJSON) } if dataset := report.DecisionDataset; dataset != nil { result.Observations = prettyValueJSON(dataset.Observations) result.ModelPredictions = prettyValueJSON(map[string]any{ "model_run": dataset.ModelRun, "evaluations": dataset.Evaluations, }) result.DeterministicRecommendation = prettyValueJSON( dataset.Recommendation, ) result.HumanReviews = prettyValueJSON(dataset.HumanReviews) } if outcome := report.Outcome; outcome != nil { result.Outcome = &ExecutionReportOutcome{ ResultType: outcome.ResultType, Outcome: stringValue(outcome.Outcome), OperatorReason: stringValue(outcome.OperatorReason), ErrorCode: stringValue(outcome.ErrorCode), ErrorMessage: stringValue(outcome.ErrorMessage), ErrorStep: stringValue(outcome.ErrorStep), OrderSubmitted: outcome.OrderSubmitted, ReceivedAt: outcome.ReceivedAt, ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry, } } return result } func prettyAuditJSON(value *string) string { if value == nil || strings.TrimSpace(*value) == "" { return "" } var decoded any if err := json.Unmarshal([]byte(*value), &decoded); err != nil { return "" } formatted, err := json.MarshalIndent(decoded, "", " ") if err != nil { return "" } return string(formatted) } func prettyValueJSON(value any) string { if value == nil { return "" } formatted, err := json.MarshalIndent(value, "", " ") if err != nil || string(formatted) == "null" || string(formatted) == "[]" { return "" } return string(formatted) } func stringValue(value *string) string { if value == nil { return "" } return *value } func mapUsecaseError(err error) error { var typed *usecase.Error if !errors.As(err, &typed) { return err } var public error switch typed.Kind { case usecase.ErrorKindInvalid: if typed.Code == "TASK_CANCEL_INVALID" { public = ErrNotFound } else if typed.Code == "ASSET_TOO_LARGE" || typed.Code == "ASSET_MEDIA_TYPE_UNSUPPORTED" || typed.Code == "ASSET_IMAGE_INVALID" || typed.Code == "ASSET_FILE_REQUIRED" { public = ErrInvalidFile } else { public = ErrValidation } case usecase.ErrorKindNotFound: public = ErrNotFound case usecase.ErrorKindConflict: public = ErrConflict case usecase.ErrorKindUnavailable: public = ErrUnavailable default: return err } return &adapterError{ public: public, cause: err, } } type adapterError struct { public error cause error } func (err *adapterError) Error() string { return err.public.Error() } func (err *adapterError) Unwrap() []error { return []error{err.public, err.cause} } var _ Service = (*UsecaseAdapter)(nil) var _ FreightService = (*UsecaseAdapter)(nil) var _ ProcurementService = (*UsecaseAdapter)(nil)