959 lines
27 KiB
Go
959 lines
27 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
"cmroubao/backend-api/internal/platform/shunyunbao"
|
|
"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
|
|
erp *shunyunbao.SessionManager
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) SetProcurement(
|
|
procurement *usecase.ProcurementService,
|
|
) {
|
|
adapter.procurement = procurement
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) SetERPConnection(
|
|
manager *shunyunbao.SessionManager,
|
|
) {
|
|
adapter.erp = manager
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) ERPConnectionStatus(
|
|
context.Context,
|
|
) (ERPConnectionStatus, error) {
|
|
if adapter.erp == nil {
|
|
return ERPConnectionStatus{}, ErrUnavailable
|
|
}
|
|
return erpConnectionStatusFrom(adapter.erp.Status()), nil
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) RequestERPCaptcha(
|
|
ctx context.Context,
|
|
) (ERPConnectionStatus, error) {
|
|
if adapter.erp == nil {
|
|
return ERPConnectionStatus{}, ErrUnavailable
|
|
}
|
|
status, err := adapter.erp.FetchCaptcha(ctx)
|
|
if err != nil {
|
|
return erpConnectionStatusFrom(status), mapERPError(err)
|
|
}
|
|
return erpConnectionStatusFrom(status), nil
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) OpenERPCaptcha(
|
|
_ context.Context,
|
|
ticket string,
|
|
) (ERPCaptchaImage, error) {
|
|
if adapter.erp == nil {
|
|
return ERPCaptchaImage{}, ErrUnavailable
|
|
}
|
|
image, err := adapter.erp.OpenCaptcha(ticket)
|
|
if err != nil {
|
|
return ERPCaptchaImage{}, mapERPError(err)
|
|
}
|
|
return ERPCaptchaImage{
|
|
Content: image.Content,
|
|
ContentType: image.ContentType,
|
|
}, nil
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) LoginERP(
|
|
ctx context.Context,
|
|
input ERPLoginInput,
|
|
) (ERPConnectionStatus, error) {
|
|
if adapter.erp == nil {
|
|
return ERPConnectionStatus{}, ErrUnavailable
|
|
}
|
|
status, err := adapter.erp.Login(ctx, input.CaptchaTicket, input.CaptchaCode)
|
|
if err != nil {
|
|
return erpConnectionStatusFrom(status), mapERPError(err)
|
|
}
|
|
return erpConnectionStatusFrom(status), nil
|
|
}
|
|
|
|
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 {
|
|
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
|
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
|
|
}
|
|
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
|
|
}
|
|
var result usecase.CreateFreightSyncResult
|
|
var err error
|
|
if input.Mode == domain.FreightSyncCreatedRange {
|
|
result, err = adapter.freight.CreateDateSync(
|
|
ctx,
|
|
usecase.CreateFreightDateSyncCommand{
|
|
CreatorSubject: localAdminSubject,
|
|
ActorUserID: input.ActorUserID,
|
|
IdempotencyKey: input.IdempotencyKey,
|
|
CreatedFrom: input.CreatedFrom,
|
|
CreatedTo: input.CreatedTo,
|
|
SyncToNow: input.SyncToNow,
|
|
},
|
|
)
|
|
} else {
|
|
result, err = adapter.freight.CreateOrderSync(
|
|
ctx,
|
|
usecase.CreateFreightSyncCommand{
|
|
CreatorSubject: localAdminSubject,
|
|
ActorUserID: input.ActorUserID,
|
|
IdempotencyKey: input.IdempotencyKey,
|
|
OrderNumber: input.OrderNumber,
|
|
},
|
|
)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
|
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
|
|
}
|
|
return FreightSync{}, mapUsecaseError(err)
|
|
}
|
|
return freightSyncFrom(result.Run), nil
|
|
}
|
|
|
|
func (adapter *UsecaseAdapter) GetFreightWatermark(
|
|
ctx context.Context,
|
|
) (*FreightWatermark, error) {
|
|
if adapter.freight == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
watermark, err := adapter.freight.GetWatermark(ctx, localAdminSubject)
|
|
if err != nil {
|
|
return nil, mapUsecaseError(err)
|
|
}
|
|
if watermark == nil {
|
|
return nil, nil
|
|
}
|
|
return &FreightWatermark{
|
|
LastSuccessfulTo: watermark.LastSuccessfulTo,
|
|
LastSuccessfulRunID: watermark.LastSuccessfulRunID,
|
|
UpdatedAt: watermark.UpdatedAt,
|
|
}, 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,
|
|
Mode: run.Mode,
|
|
CreatedFrom: run.CreatedFrom,
|
|
CreatedTo: run.CreatedTo,
|
|
Status: string(run.Status),
|
|
ErrorCode: stringValue(run.ErrorCode),
|
|
OrderCount: run.OrderCount,
|
|
ItemCount: run.ItemCount,
|
|
CreatedAt: run.CreatedAt,
|
|
}
|
|
if run.WatermarkThrough != nil {
|
|
result.WatermarkThrough = *run.WatermarkThrough
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
func mapERPError(err error) error {
|
|
var public error
|
|
switch {
|
|
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
|
|
public = ErrERPNotConfigured
|
|
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
|
|
public = ErrERPSessionNeeded
|
|
case errors.Is(err, shunyunbao.ErrCaptchaTicketInvalid):
|
|
public = ErrERPCaptchaInvalid
|
|
case errors.Is(err, shunyunbao.ErrLoginRejected):
|
|
public = ErrERPLoginRejected
|
|
case errors.Is(err, domain.ErrFreightSourceProtocol):
|
|
public = ErrERPProtocol
|
|
case errors.Is(err, domain.ErrFreightSourceUnavailable):
|
|
public = ErrUnavailable
|
|
default:
|
|
return err
|
|
}
|
|
return &adapterError{public: public, cause: err}
|
|
}
|
|
|
|
func erpConnectionStatusFrom(
|
|
status shunyunbao.SessionStatus,
|
|
) ERPConnectionStatus {
|
|
return ERPConnectionStatus{
|
|
Configured: status.Configured,
|
|
Authenticated: status.Authenticated,
|
|
CaptchaReady: status.CaptchaReady,
|
|
CaptchaTicket: status.CaptchaTicket,
|
|
}
|
|
}
|
|
|
|
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)
|
|
var _ ERPConnectionService = (*UsecaseAdapter)(nil)
|