Files
cmroubao/backend-api/internal/usecase/procurement_service.go
T

529 lines
14 KiB
Go

package usecase
import (
"context"
"errors"
"strconv"
"strings"
"time"
"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
store ReferenceImageStore
clock Clock
ids IDGenerator
}
type ProcurementServiceOption func(*ProcurementService) error
func WithProcurementReferenceStore(
store ReferenceImageStore,
) ProcurementServiceOption {
return func(service *ProcurementService) error {
if store == nil {
return errors.New("procurement reference store is required")
}
service.store = store
return nil
}
}
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,
options ...ProcurementServiceOption,
) (*ProcurementService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("procurement service dependencies are required")
}
service := &ProcurementService{
repository: repository,
clock: clock,
ids: ids,
}
for _, option := range options {
if option == nil {
return nil, errors.New("procurement service option is required")
}
if err := option(service); err != nil {
return nil, err
}
}
return service, 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
}
var auto *AutoProcurementReference
if request.Status == domain.ProcurementNeedsImage &&
service.store != nil {
auto, err = service.copyFreightImageReference(ctx, request, now)
if err != nil {
return CreateProcurementRequestResult{}, err
}
}
stored, created, referenceUsed, err :=
service.repository.CreateProcurementRequest(
ctx,
request,
auto,
)
if err != nil {
service.cleanupAutomaticReference(auto)
return CreateProcurementRequestResult{}, wrapProcurementError(err)
}
if !referenceUsed {
service.cleanupAutomaticReference(auto)
}
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, replacedStorageKey, err :=
service.repository.BindProcurementReference(
ctx,
command.CreatorSubject,
command.RequestID,
command.ImageAssetID,
service.clock.Now().UTC(),
)
if err != nil {
return domain.ProcurementRequest{}, wrapProcurementError(err)
}
if replacedStorageKey != nil {
service.deleteStoredReference(*replacedStorageKey)
}
return request, nil
}
func (service *ProcurementService) copyFreightImageReference(
ctx context.Context,
request domain.ProcurementRequest,
now time.Time,
) (*AutoProcurementReference, error) {
image, err := service.repository.GetReadyFreightItemImage(
ctx,
request.CreatorSubject,
request.FreightOrderItemID,
)
if errors.Is(err, ErrRepositoryNotFound) {
return nil, nil
}
if err != nil {
return nil, wrapRepositoryError(err)
}
assetID, err := service.ids.NewID()
if err != nil {
return nil, procurementInternal(err)
}
content, err := service.store.Open(ctx, image.StorageKey)
if err != nil {
return nil, mapImageStoreError(err)
}
normalized, putErr := service.store.Put(
ctx,
assetID,
image.MediaType,
content,
)
closeErr := content.Close()
if putErr != nil {
return nil, mapImageStoreError(putErr)
}
if closeErr != nil {
service.deleteStoredReference(normalized.StorageKey)
return nil, procurementInternal(closeErr)
}
return &AutoProcurementReference{
Asset: domain.Asset{
ID: assetID,
CreatorSubject: request.CreatorSubject,
Purpose: domain.AssetPurposeTaskReference,
MediaType: normalized.MediaType,
SizeBytes: normalized.SizeBytes,
SHA256: normalized.SHA256,
StorageKey: normalized.StorageKey,
CreatedAt: now,
},
ProductThumbRef: image.ProductThumbRef,
SourceImageSHA256: image.SHA256,
SourceImageStorageKey: image.StorageKey,
}, nil
}
func (service *ProcurementService) cleanupAutomaticReference(
auto *AutoProcurementReference,
) {
if auto != nil {
service.deleteStoredReference(auto.Asset.StorageKey)
}
}
func (service *ProcurementService) deleteStoredReference(storageKey string) {
if service.store == nil || storageKey == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = service.store.Delete(ctx, storageKey)
}
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,
)
}