feat(t223): generate tasks from freight items
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type ProcurementRepository interface {
|
||||
GetProcurementSourceItem(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.ProcurementSourceItem, error)
|
||||
CreateProcurementRequest(
|
||||
context.Context,
|
||||
domain.ProcurementRequest,
|
||||
) (domain.ProcurementRequest, bool, error)
|
||||
ListProcurementRequestsForOrder(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) ([]domain.ProcurementRequest, error)
|
||||
GetProcurementRequest(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.ProcurementRequest, error)
|
||||
BindProcurementReference(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
time.Time,
|
||||
) (domain.ProcurementRequest, error)
|
||||
CreateProcurementTask(
|
||||
context.Context,
|
||||
domain.ProcurementRequest,
|
||||
domain.PurchaseTask,
|
||||
domain.TaskEvent,
|
||||
domain.PurchaseTaskSource,
|
||||
string,
|
||||
string,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user