413 lines
11 KiB
Go
413 lines
11 KiB
Go
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,
|
||
|
|
)
|
||
|
|
}
|