package usecase import ( "context" "errors" "strconv" "strings" "unicode/utf8" "cmroubao/backend-api/internal/domain" ) var ( ErrProcurementStateConflict = errors.New( "procurement request state conflict", ) ErrProcurementSourceChanged = errors.New( "procurement request source changed", ) ) type ProcurementService struct { repository ProcurementRepository clock Clock ids IDGenerator } type CreateProcurementRequestCommand struct { CreatorSubject string ActorUserID string FreightOrderItemID string ConfirmProcurementNeeded bool } type CreateProcurementRequestResult struct { Request domain.ProcurementRequest Replayed bool } type BindProcurementReferenceCommand struct { CreatorSubject string ActorUserID string RequestID string ImageAssetID string } type CreateProcurementTaskCommand struct { CreatorSubject string ActorUserID string RequestID string IdempotencyKey string } type CreateProcurementTaskResult struct { Task domain.PurchaseTask Replayed bool } func NewProcurementService( repository ProcurementRepository, clock Clock, ids IDGenerator, ) (*ProcurementService, error) { if repository == nil || clock == nil || ids == nil { return nil, errors.New("procurement service dependencies are required") } return &ProcurementService{ repository: repository, clock: clock, ids: ids, }, nil } func (service *ProcurementService) CreateRequest( ctx context.Context, command CreateProcurementRequestCommand, ) (CreateProcurementRequestResult, error) { command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) command.ActorUserID = strings.TrimSpace(command.ActorUserID) command.FreightOrderItemID = strings.TrimSpace( command.FreightOrderItemID, ) fields := map[string]string{} if command.CreatorSubject == "" { fields["creator_subject"] = "is required" } if !isUUID(command.ActorUserID) { fields["actor_user_id"] = "must be a UUID" } if !isUUID(command.FreightOrderItemID) { fields["freight_order_item_id"] = "must be a UUID" } if !command.ConfirmProcurementNeeded { fields["confirm_procurement_needed"] = "must be explicitly confirmed" } if len(fields) > 0 { return CreateProcurementRequestResult{}, invalidError( "PROCUREMENT_REQUEST_INVALID", "procurement request is invalid", fields, ) } source, err := service.repository.GetProcurementSourceItem( ctx, command.CreatorSubject, command.FreightOrderItemID, ) if err != nil { return CreateProcurementRequestResult{}, wrapRepositoryError(err) } requestID, err := service.ids.NewID() if err != nil { return CreateProcurementRequestResult{}, procurementInternal(err) } now := service.clock.Now().UTC() request := domain.ProcurementRequest{ ID: requestID, CreatorSubject: command.CreatorSubject, FreightOrderItemID: source.Item.ID, SourceRevision: source.Item.Revision, SourceSHA256: source.Item.CanonicalSHA256, Title: strings.TrimSpace(source.Item.Title), ProductSpec: strings.TrimSpace(source.Item.ProductSpec), SKU: strings.TrimSpace(source.Item.SKU), Quantity: source.Item.Quantity, SourcePurchaseStatus: source.Item.PurchaseStatus, SourceIsCanceled: source.IsCanceled, ProcurementConfirmedByUserID: command.ActorUserID, ProcurementConfirmedAt: now, Status: domain.ProcurementNeedsImage, CreatedAt: now, UpdatedAt: now, } if code := procurementBlockingCode(request); code != "" { request.Status = domain.ProcurementBlocked request.BlockingCode = &code } stored, created, err := service.repository.CreateProcurementRequest( ctx, request, ) if err != nil { return CreateProcurementRequestResult{}, wrapProcurementError(err) } return CreateProcurementRequestResult{ Request: stored, Replayed: !created, }, nil } func (service *ProcurementService) ListForOrder( ctx context.Context, creatorSubject, orderID string, ) ([]domain.ProcurementRequest, error) { requests, err := service.repository.ListProcurementRequestsForOrder( ctx, strings.TrimSpace(creatorSubject), strings.TrimSpace(orderID), ) if err != nil { return nil, wrapProcurementError(err) } return requests, nil } func (service *ProcurementService) Get( ctx context.Context, creatorSubject, requestID string, ) (domain.ProcurementRequest, error) { request, err := service.repository.GetProcurementRequest( ctx, strings.TrimSpace(creatorSubject), strings.TrimSpace(requestID), ) if err != nil { return domain.ProcurementRequest{}, wrapProcurementError(err) } return request, nil } func (service *ProcurementService) BindReference( ctx context.Context, command BindProcurementReferenceCommand, ) (domain.ProcurementRequest, error) { command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) command.ActorUserID = strings.TrimSpace(command.ActorUserID) command.RequestID = strings.TrimSpace(command.RequestID) command.ImageAssetID = strings.TrimSpace(command.ImageAssetID) fields := map[string]string{} if command.CreatorSubject == "" { fields["creator_subject"] = "is required" } if !isUUID(command.ActorUserID) { fields["actor_user_id"] = "must be a UUID" } if !isUUID(command.RequestID) { fields["procurement_request_id"] = "must be a UUID" } if !isUUID(command.ImageAssetID) { fields["image_asset_id"] = "must be a UUID" } if len(fields) > 0 { return domain.ProcurementRequest{}, invalidError( "PROCUREMENT_REFERENCE_INVALID", "procurement reference is invalid", fields, ) } request, err := service.repository.BindProcurementReference( ctx, command.CreatorSubject, command.RequestID, command.ImageAssetID, service.clock.Now().UTC(), ) if err != nil { return domain.ProcurementRequest{}, wrapProcurementError(err) } return request, nil } func (service *ProcurementService) CreateTask( ctx context.Context, command CreateProcurementTaskCommand, ) (CreateProcurementTaskResult, error) { if err := validateWriteIdentity( command.CreatorSubject, command.IdempotencyKey, ); err != nil { return CreateProcurementTaskResult{}, err } command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) command.ActorUserID = strings.TrimSpace(command.ActorUserID) command.RequestID = strings.TrimSpace(command.RequestID) if !isUUID(command.ActorUserID) || !isUUID(command.RequestID) { return CreateProcurementTaskResult{}, invalidError( "PROCUREMENT_TASK_INVALID", "procurement task request is invalid", map[string]string{ "request": "actor and procurement request must be UUIDs", }, ) } request, err := service.repository.GetProcurementRequest( ctx, command.CreatorSubject, command.RequestID, ) if err != nil { return CreateProcurementTaskResult{}, wrapProcurementError(err) } if request.Status != domain.ProcurementTaskCreated && request.SourceChanged { return CreateProcurementTaskResult{}, wrapProcurementError( ErrProcurementSourceChanged, ) } if request.Status != domain.ProcurementReady && request.Status != domain.ProcurementTaskCreated { return CreateProcurementTaskResult{}, wrapProcurementError( ErrProcurementStateConflict, ) } if request.ReferenceAssetID == nil { return CreateProcurementTaskResult{}, wrapProcurementError( ErrProcurementStateConflict, ) } taskID, err := service.ids.NewID() if err != nil { return CreateProcurementTaskResult{}, procurementInternal(err) } eventID, err := service.ids.NewID() if err != nil { return CreateProcurementTaskResult{}, procurementInternal(err) } now := service.clock.Now().UTC() sourceRefValue := "erp-procurement:" + request.ID + ":r" + strconv.Itoa(request.SourceRevision) actorUserID := command.ActorUserID description := "" if request.ProductSpec != "" { description = "ERP 规格:" + request.ProductSpec } task := domain.PurchaseTask{ ID: taskID, CreatorSubject: command.CreatorSubject, CreatedByUserID: &actorUserID, SourceRef: &sourceRefValue, Title: request.Title, Description: description, SKU: request.SKU, ImageAssetID: *request.ReferenceAssetID, Quantity: *request.Quantity, Currency: domain.CurrencyCNY, Status: domain.TaskStatusPending, Version: 1, CreatedAt: now, UpdatedAt: now, } if err := domain.ValidateTaskInput( task.CreatorSubject, task.SourceRef, task.Title, task.Description, task.SKU, task.ImageAssetID, task.Quantity, ); err != nil { return CreateProcurementTaskResult{}, wrapProcurementError( ErrProcurementStateConflict, ) } event := domain.TaskEvent{ ID: eventID, TaskID: task.ID, ActorUserID: &actorUserID, Type: "TASK_CREATED", Message: "task created from procurement request", OccurredAt: now, } source := domain.PurchaseTaskSource{ TaskID: task.ID, ProcurementRequestID: request.ID, FreightOrderItemID: request.FreightOrderItemID, SourceRevision: request.SourceRevision, SourceSHA256: request.SourceSHA256, CreatedAt: now, } requestHash := hashJSON(struct { RequestID string `json:"request_id"` SourceSHA256 string `json:"source_sha256"` }{ RequestID: request.ID, SourceSHA256: request.SourceSHA256, }) created, wasCreated, err := service.repository.CreateProcurementTask( ctx, request, task, event, source, strings.TrimSpace(command.IdempotencyKey), requestHash, ) if err != nil { return CreateProcurementTaskResult{}, wrapProcurementError(err) } return CreateProcurementTaskResult{ Task: created, Replayed: !wasCreated, }, nil } func procurementBlockingCode( request domain.ProcurementRequest, ) string { if request.SourceIsCanceled != nil && *request.SourceIsCanceled { return domain.ProcurementBlockSourceCanceled } if request.Title == "" || utf8.RuneCountInString(request.Title) > domain.MaxTitleRunes || len([]byte(request.Title)) > domain.MaxTitleBytes { return domain.ProcurementBlockTitleRequired } if request.SKU == "" || len([]byte(request.SKU)) > domain.MaxSKUBytes { return domain.ProcurementBlockSKURequired } if request.Quantity == nil || *request.Quantity <= 0 { return domain.ProcurementBlockQuantityRequired } return "" } func wrapProcurementError(err error) error { switch { case errors.Is(err, ErrProcurementSourceChanged): return newError( ErrorKindConflict, "PROCUREMENT_SOURCE_CHANGED", "freight source changed; create a request for the current revision", err, ) case errors.Is(err, ErrProcurementStateConflict): return newError( ErrorKindConflict, "PROCUREMENT_STATE_CONFLICT", "procurement request state does not allow this operation", err, ) case errors.Is(err, ErrAssetUnavailable): return newError( ErrorKindConflict, "PROCUREMENT_ASSET_UNAVAILABLE", "reference image is not available for this procurement request", err, ) default: return wrapRepositoryError(err) } } func procurementInternal(err error) error { return newError( ErrorKindInternal, "INTERNAL_ERROR", "internal server error", err, ) }