feat(t223): generate tasks from freight items
This commit is contained in:
@@ -243,6 +243,10 @@ func buildRouter(
|
|||||||
if _, err := freight.RecoverInterrupted(ctx); err != nil {
|
if _, err := freight.RecoverInterrupted(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
procurement, err := usecase.NewProcurementService(store, clock, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
passwords, err := password.NewBcrypt(12)
|
passwords, err := password.NewBcrypt(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -280,6 +284,7 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
webService.SetProcurement(procurement)
|
||||||
renderer, err := webui.NewRenderer()
|
renderer, err := webui.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -322,6 +327,7 @@ func buildRouter(
|
|||||||
Results: results,
|
Results: results,
|
||||||
Authorizations: authorizations,
|
Authorizations: authorizations,
|
||||||
Freight: freight,
|
Freight: freight,
|
||||||
|
Procurement: procurement,
|
||||||
},
|
},
|
||||||
webHandler,
|
webHandler,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ProcurementRequestStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProcurementBlocked ProcurementRequestStatus = "BLOCKED"
|
||||||
|
ProcurementNeedsImage ProcurementRequestStatus = "NEEDS_IMAGE"
|
||||||
|
ProcurementReady ProcurementRequestStatus = "READY"
|
||||||
|
ProcurementTaskCreated ProcurementRequestStatus = "TASK_CREATED"
|
||||||
|
ProcurementSourceChanged ProcurementRequestStatus = "SOURCE_CHANGED"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProcurementBlockSourceCanceled = "SOURCE_CANCELED"
|
||||||
|
ProcurementBlockTitleRequired = "TITLE_REQUIRED"
|
||||||
|
ProcurementBlockSKURequired = "SKU_REQUIRED"
|
||||||
|
ProcurementBlockQuantityRequired = "QUANTITY_REQUIRED"
|
||||||
|
ProcurementBlockSourceChanged = "SOURCE_CHANGED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcurementSourceItem struct {
|
||||||
|
Item FreightOrderItem
|
||||||
|
IsCanceled *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcurementRequest struct {
|
||||||
|
ID string
|
||||||
|
CreatorSubject string
|
||||||
|
FreightOrderItemID string
|
||||||
|
SourceRevision int
|
||||||
|
SourceSHA256 string
|
||||||
|
Title string
|
||||||
|
ProductSpec string
|
||||||
|
SKU string
|
||||||
|
Quantity *int
|
||||||
|
SourcePurchaseStatus *string
|
||||||
|
SourceIsCanceled *bool
|
||||||
|
ProcurementConfirmedByUserID string
|
||||||
|
ProcurementConfirmedAt time.Time
|
||||||
|
Status ProcurementRequestStatus
|
||||||
|
BlockingCode *string
|
||||||
|
ReferenceAssetID *string
|
||||||
|
PurchaseTaskID *string
|
||||||
|
SourceChanged bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchaseTaskSource struct {
|
||||||
|
TaskID string
|
||||||
|
ProcurementRequestID string
|
||||||
|
FreightOrderItemID string
|
||||||
|
SourceRevision int
|
||||||
|
SourceSHA256 string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("initial Up() error = %v", err)
|
t.Fatalf("initial Up() error = %v", err)
|
||||||
} else if applied != 12 {
|
} else if applied != 13 {
|
||||||
t.Fatalf("initial Up() applied = %d, want 12", applied)
|
t.Fatalf("initial Up() applied = %d, want 13", applied)
|
||||||
|
}
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("initial Down(v13) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := runner.Down(ctx); err != nil {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("initial Down(v12) error = %v", err)
|
t.Fatalf("initial Down(v12) error = %v", err)
|
||||||
@@ -65,9 +68,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
seedClaimsHistoricalFixture(t, db)
|
seedClaimsHistoricalFixture(t, db)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("Up(v5-v12) over historical data error = %v", err)
|
t.Fatalf("Up(v5-v13) over historical data error = %v", err)
|
||||||
} else if applied != 8 {
|
} else if applied != 9 {
|
||||||
t.Fatalf("Up(v5-v12) applied = %d, want 8", applied)
|
t.Fatalf("Up(v5-v13) applied = %d, want 9", applied)
|
||||||
|
}
|
||||||
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("Down(v13) with compatible history error = %v", err)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
@@ -117,9 +125,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
assertClaimsHistory(t, db, false)
|
assertClaimsHistory(t, db, false)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("final Up(v4-v12) error = %v", err)
|
t.Fatalf("final Up(v4-v13) error = %v", err)
|
||||||
} else if applied != 9 {
|
} else if applied != 10 {
|
||||||
t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied)
|
t.Fatalf("final Up(v4-v13) applied = %d, want 10", applied)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
}
|
}
|
||||||
@@ -355,6 +363,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
|||||||
t.Fatalf("insert v4 audit event: %v", err)
|
t.Fatalf("insert v4 audit event: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("Down(v13) error = %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(ctx); err != nil {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("Down(v12) error = %v", err)
|
t.Fatalf("Down(v12) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Up() error = %v", err)
|
t.Fatalf("Up() error = %v", err)
|
||||||
}
|
}
|
||||||
if applied != 12 {
|
if applied != 13 {
|
||||||
t.Fatalf("Up() applied = %d, want 12", applied)
|
t.Fatalf("Up() applied = %d, want 13", applied)
|
||||||
}
|
}
|
||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
@@ -43,6 +43,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
10: true,
|
10: true,
|
||||||
11: true,
|
11: true,
|
||||||
12: true,
|
12: true,
|
||||||
|
13: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -68,7 +69,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
9: true,
|
9: true,
|
||||||
10: true,
|
10: true,
|
||||||
11: true,
|
11: true,
|
||||||
12: false,
|
12: true,
|
||||||
|
13: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -91,6 +93,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
10: true,
|
10: true,
|
||||||
11: true,
|
11: true,
|
||||||
12: true,
|
12: true,
|
||||||
|
13: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Down(v13) error = %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("Down(v12) error = %v", err)
|
t.Fatalf("Down(v12) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -426,9 +429,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||||
}
|
}
|
||||||
if applied, err := runner.Up(context.Background()); err != nil {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("Up(v3-v12) error = %v", err)
|
t.Fatalf("Up(v3-v13) error = %v", err)
|
||||||
} else if applied != 10 {
|
} else if applied != 11 {
|
||||||
t.Fatalf("Up(v3-v12) applied = %d, want 10", applied)
|
t.Fatalf("Up(v3-v13) applied = %d, want 11", applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("procurement migration down: %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(ctx); err == nil {
|
if err := runner.Down(ctx); err == nil {
|
||||||
t.Fatal("freight migration down succeeded with retained source data")
|
t.Fatal("freight migration down succeeded with retained source data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createProcurementTaskOperation = "CREATE_PROCUREMENT_TASK"
|
||||||
|
|
||||||
|
func (store *Store) GetProcurementSourceItem(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject, itemID string,
|
||||||
|
) (domain.ProcurementSourceItem, error) {
|
||||||
|
item, err := scanFreightOrderItem(store.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT
|
||||||
|
item.id, item.freight_order_id, item.external_item_id,
|
||||||
|
item.title, item.product_spec, item.sku, item.quantity,
|
||||||
|
item.product_thumb_ref, item.purchase_status,
|
||||||
|
item.canonical_sha256, item.revision, item.is_present,
|
||||||
|
item.first_sync_run_id, item.last_sync_run_id,
|
||||||
|
item.created_at, item.updated_at
|
||||||
|
FROM freight_order_items AS item
|
||||||
|
JOIN freight_orders AS freight
|
||||||
|
ON freight.id = item.freight_order_id
|
||||||
|
WHERE freight.creator_subject = ? AND item.id = ?
|
||||||
|
AND item.is_present = 1`,
|
||||||
|
creatorSubject,
|
||||||
|
itemID,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.ProcurementSourceItem{}, usecase.ErrRepositoryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementSourceItem{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
var canceled sql.NullBool
|
||||||
|
if err := store.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT freight.is_canceled
|
||||||
|
FROM freight_orders AS freight
|
||||||
|
JOIN freight_order_items AS item
|
||||||
|
ON item.freight_order_id = freight.id
|
||||||
|
WHERE freight.creator_subject = ? AND item.id = ?`,
|
||||||
|
creatorSubject,
|
||||||
|
itemID,
|
||||||
|
).Scan(&canceled); err != nil {
|
||||||
|
return domain.ProcurementSourceItem{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
var isCanceled *bool
|
||||||
|
if canceled.Valid {
|
||||||
|
isCanceled = &canceled.Bool
|
||||||
|
}
|
||||||
|
return domain.ProcurementSourceItem{
|
||||||
|
Item: item,
|
||||||
|
IsCanceled: isCanceled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateProcurementRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
candidate domain.ProcurementRequest,
|
||||||
|
) (domain.ProcurementRequest, bool, error) {
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
existing, err := getProcurementRequestBySource(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
candidate.CreatorSubject,
|
||||||
|
candidate.FreightOrderItemID,
|
||||||
|
candidate.SourceRevision,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return existing, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, usecase.ErrRepositoryNotFound) {
|
||||||
|
return domain.ProcurementRequest{}, false, err
|
||||||
|
}
|
||||||
|
var currentRevision int
|
||||||
|
var currentSHA string
|
||||||
|
var present bool
|
||||||
|
if err := tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT item.revision, item.canonical_sha256, item.is_present
|
||||||
|
FROM freight_order_items AS item
|
||||||
|
JOIN freight_orders AS freight
|
||||||
|
ON freight.id = item.freight_order_id
|
||||||
|
WHERE freight.creator_subject = ? AND item.id = ?`,
|
||||||
|
candidate.CreatorSubject,
|
||||||
|
candidate.FreightOrderItemID,
|
||||||
|
).Scan(¤tRevision, ¤tSHA, &present); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.ProcurementRequest{}, false, usecase.ErrRepositoryNotFound
|
||||||
|
}
|
||||||
|
return domain.ProcurementRequest{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if !present || currentRevision != candidate.SourceRevision ||
|
||||||
|
currentSHA != candidate.SourceSHA256 {
|
||||||
|
return domain.ProcurementRequest{}, false,
|
||||||
|
usecase.ErrProcurementSourceChanged
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO procurement_requests (
|
||||||
|
id, creator_subject, freight_order_item_id, source_revision,
|
||||||
|
source_sha256, title, product_spec, sku, quantity,
|
||||||
|
source_purchase_status, source_is_canceled,
|
||||||
|
procurement_confirmed_by_user_id, procurement_confirmed_at,
|
||||||
|
status, blocking_code, reference_asset_id, purchase_task_id,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
||||||
|
candidate.ID,
|
||||||
|
candidate.CreatorSubject,
|
||||||
|
candidate.FreightOrderItemID,
|
||||||
|
candidate.SourceRevision,
|
||||||
|
candidate.SourceSHA256,
|
||||||
|
candidate.Title,
|
||||||
|
candidate.ProductSpec,
|
||||||
|
candidate.SKU,
|
||||||
|
nullableFreightQuantity(candidate.Quantity),
|
||||||
|
nullableString(candidate.SourcePurchaseStatus),
|
||||||
|
nullableBool(candidate.SourceIsCanceled),
|
||||||
|
candidate.ProcurementConfirmedByUserID,
|
||||||
|
formatTimestamp(candidate.ProcurementConfirmedAt),
|
||||||
|
candidate.Status,
|
||||||
|
nullableString(candidate.BlockingCode),
|
||||||
|
formatTimestamp(candidate.CreatedAt),
|
||||||
|
formatTimestamp(candidate.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return candidate, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListProcurementRequestsForOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject, orderID string,
|
||||||
|
) ([]domain.ProcurementRequest, error) {
|
||||||
|
rows, err := store.db.QueryContext(
|
||||||
|
ctx,
|
||||||
|
procurementRequestSelect+`
|
||||||
|
WHERE request.creator_subject = ?
|
||||||
|
AND item.freight_order_id = ?
|
||||||
|
ORDER BY item.id, request.source_revision DESC, request.id DESC`,
|
||||||
|
creatorSubject,
|
||||||
|
orderID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
requests := make([]domain.ProcurementRequest, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
request, err := scanProcurementRequest(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
requests = append(requests, request)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return requests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetProcurementRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject, requestID string,
|
||||||
|
) (domain.ProcurementRequest, error) {
|
||||||
|
return getProcurementRequest(ctx, store.db, creatorSubject, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) BindProcurementReference(
|
||||||
|
ctx context.Context,
|
||||||
|
creatorSubject, requestID, assetID string,
|
||||||
|
now time.Time,
|
||||||
|
) (domain.ProcurementRequest, error) {
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
request, err := getProcurementRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
creatorSubject,
|
||||||
|
requestID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, err
|
||||||
|
}
|
||||||
|
if request.SourceChanged {
|
||||||
|
if request.PurchaseTaskID == nil {
|
||||||
|
if err := markProcurementSourceChanged(ctx, tx, request.ID, now); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return domain.ProcurementRequest{}, usecase.ErrProcurementSourceChanged
|
||||||
|
}
|
||||||
|
if request.ReferenceAssetID != nil &&
|
||||||
|
*request.ReferenceAssetID == assetID &&
|
||||||
|
(request.Status == domain.ProcurementReady ||
|
||||||
|
request.Status == domain.ProcurementTaskCreated) {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
if request.Status != domain.ProcurementNeedsImage {
|
||||||
|
return domain.ProcurementRequest{}, usecase.ErrProcurementStateConflict
|
||||||
|
}
|
||||||
|
var available int
|
||||||
|
if err := tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM assets AS asset
|
||||||
|
WHERE asset.id = ? AND asset.creator_subject = ?
|
||||||
|
AND asset.purpose = 'TASK_REFERENCE'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM purchase_tasks AS task
|
||||||
|
WHERE task.image_asset_id = asset.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM procurement_requests AS other
|
||||||
|
WHERE other.reference_asset_id = asset.id
|
||||||
|
AND other.id != ?
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
assetID,
|
||||||
|
creatorSubject,
|
||||||
|
requestID,
|
||||||
|
).Scan(&available); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if available != 1 {
|
||||||
|
return domain.ProcurementRequest{}, usecase.ErrAssetUnavailable
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE procurement_requests
|
||||||
|
SET reference_asset_id = ?, status = 'READY', updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'NEEDS_IMAGE'`,
|
||||||
|
assetID,
|
||||||
|
formatTimestamp(now),
|
||||||
|
requestID,
|
||||||
|
); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
updated, err := getProcurementRequest(ctx, tx, creatorSubject, requestID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateProcurementTask(
|
||||||
|
ctx context.Context,
|
||||||
|
expected domain.ProcurementRequest,
|
||||||
|
task domain.PurchaseTask,
|
||||||
|
event domain.TaskEvent,
|
||||||
|
source domain.PurchaseTaskSource,
|
||||||
|
idempotencyKey, requestSHA256 string,
|
||||||
|
) (domain.PurchaseTask, bool, error) {
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
existingHash, resourceID, found, err := lookupIdempotency(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
task.CreatorSubject,
|
||||||
|
createProcurementTaskOperation,
|
||||||
|
idempotencyKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
if existingHash != requestSHA256 {
|
||||||
|
return domain.PurchaseTask{}, false, usecase.ErrIdempotencyConflict
|
||||||
|
}
|
||||||
|
existing, err := getTaskByID(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
task.CreatorSubject,
|
||||||
|
resourceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return existing, false, nil
|
||||||
|
}
|
||||||
|
request, err := getProcurementRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
task.CreatorSubject,
|
||||||
|
expected.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
if request.Status == domain.ProcurementTaskCreated &&
|
||||||
|
request.PurchaseTaskID != nil {
|
||||||
|
existing, err := getTaskByID(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
task.CreatorSubject,
|
||||||
|
*request.PurchaseTaskID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
if err := insertIdempotency(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
task.CreatorSubject,
|
||||||
|
createProcurementTaskOperation,
|
||||||
|
idempotencyKey,
|
||||||
|
requestSHA256,
|
||||||
|
"PURCHASE_TASK",
|
||||||
|
existing.ID,
|
||||||
|
task.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return existing, false, nil
|
||||||
|
}
|
||||||
|
if request.SourceChanged {
|
||||||
|
if request.PurchaseTaskID == nil {
|
||||||
|
if err := markProcurementSourceChanged(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
request.ID,
|
||||||
|
task.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return domain.PurchaseTask{}, false,
|
||||||
|
usecase.ErrProcurementSourceChanged
|
||||||
|
}
|
||||||
|
if request.Status != domain.ProcurementReady ||
|
||||||
|
request.ReferenceAssetID == nil ||
|
||||||
|
request.SourceSHA256 != expected.SourceSHA256 {
|
||||||
|
return domain.PurchaseTask{}, false,
|
||||||
|
usecase.ErrProcurementStateConflict
|
||||||
|
}
|
||||||
|
var assetAvailable int
|
||||||
|
if err := tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM assets AS asset
|
||||||
|
WHERE asset.id = ? AND asset.creator_subject = ?
|
||||||
|
AND asset.purpose = 'TASK_REFERENCE'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM purchase_tasks AS other
|
||||||
|
WHERE other.image_asset_id = asset.id
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
*request.ReferenceAssetID,
|
||||||
|
task.CreatorSubject,
|
||||||
|
).Scan(&assetAvailable); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if assetAvailable != 1 {
|
||||||
|
return domain.PurchaseTask{}, false, usecase.ErrAssetUnavailable
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO purchase_tasks (
|
||||||
|
id, creator_subject, created_by_user_id, source_ref, title,
|
||||||
|
description, sku, image_asset_id, quantity, max_budget_cents,
|
||||||
|
currency, status, version, cancel_reason, canceled_at,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'CNY', 'PENDING', 1,
|
||||||
|
NULL, NULL, ?, ?)`,
|
||||||
|
task.ID,
|
||||||
|
task.CreatorSubject,
|
||||||
|
nullableString(task.CreatedByUserID),
|
||||||
|
nullableString(task.SourceRef),
|
||||||
|
task.Title,
|
||||||
|
task.Description,
|
||||||
|
task.SKU,
|
||||||
|
task.ImageAssetID,
|
||||||
|
task.Quantity,
|
||||||
|
formatTimestamp(task.CreatedAt),
|
||||||
|
formatTimestamp(task.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if err := insertTaskEvent(ctx, tx, event); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO purchase_task_sources (
|
||||||
|
task_id, procurement_request_id, freight_order_item_id,
|
||||||
|
source_revision, source_sha256, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
source.TaskID,
|
||||||
|
source.ProcurementRequestID,
|
||||||
|
source.FreightOrderItemID,
|
||||||
|
source.SourceRevision,
|
||||||
|
source.SourceSHA256,
|
||||||
|
formatTimestamp(source.CreatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE procurement_requests
|
||||||
|
SET status = 'TASK_CREATED', purchase_task_id = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'READY'`,
|
||||||
|
task.ID,
|
||||||
|
formatTimestamp(task.CreatedAt),
|
||||||
|
request.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
changed, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if changed != 1 {
|
||||||
|
return domain.PurchaseTask{}, false,
|
||||||
|
usecase.ErrProcurementStateConflict
|
||||||
|
}
|
||||||
|
if err := insertIdempotency(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
task.CreatorSubject,
|
||||||
|
createProcurementTaskOperation,
|
||||||
|
idempotencyKey,
|
||||||
|
requestSHA256,
|
||||||
|
"PURCHASE_TASK",
|
||||||
|
task.ID,
|
||||||
|
task.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PurchaseTask{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return task, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const procurementRequestSelect = `SELECT
|
||||||
|
request.id, request.creator_subject, request.freight_order_item_id,
|
||||||
|
request.source_revision, request.source_sha256, request.title,
|
||||||
|
request.product_spec, request.sku, request.quantity,
|
||||||
|
request.source_purchase_status, request.source_is_canceled,
|
||||||
|
request.procurement_confirmed_by_user_id,
|
||||||
|
request.procurement_confirmed_at, request.status,
|
||||||
|
request.blocking_code, request.reference_asset_id,
|
||||||
|
request.purchase_task_id, request.created_at, request.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN item.revision != request.source_revision
|
||||||
|
OR item.canonical_sha256 != request.source_sha256
|
||||||
|
OR item.is_present != 1
|
||||||
|
OR COALESCE(freight.is_canceled, -1)
|
||||||
|
!= COALESCE(request.source_is_canceled, -1)
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
FROM procurement_requests AS request
|
||||||
|
JOIN freight_order_items AS item
|
||||||
|
ON item.id = request.freight_order_item_id
|
||||||
|
JOIN freight_orders AS freight
|
||||||
|
ON freight.id = item.freight_order_id
|
||||||
|
`
|
||||||
|
|
||||||
|
func getProcurementRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
query queryRower,
|
||||||
|
creatorSubject, requestID string,
|
||||||
|
) (domain.ProcurementRequest, error) {
|
||||||
|
request, err := scanProcurementRequest(query.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
procurementRequestSelect+`
|
||||||
|
WHERE request.creator_subject = ? AND request.id = ?`,
|
||||||
|
creatorSubject,
|
||||||
|
requestID,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.ProcurementRequest{}, usecase.ErrRepositoryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProcurementRequestBySource(
|
||||||
|
ctx context.Context,
|
||||||
|
query queryRower,
|
||||||
|
creatorSubject, itemID string,
|
||||||
|
revision int,
|
||||||
|
) (domain.ProcurementRequest, error) {
|
||||||
|
request, err := scanProcurementRequest(query.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
procurementRequestSelect+`
|
||||||
|
WHERE request.creator_subject = ?
|
||||||
|
AND request.freight_order_item_id = ?
|
||||||
|
AND request.source_revision = ?`,
|
||||||
|
creatorSubject,
|
||||||
|
itemID,
|
||||||
|
revision,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.ProcurementRequest{}, usecase.ErrRepositoryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanProcurementRequest(
|
||||||
|
scanner rowScanner,
|
||||||
|
) (domain.ProcurementRequest, error) {
|
||||||
|
var request domain.ProcurementRequest
|
||||||
|
var quantity sql.NullInt64
|
||||||
|
var purchaseStatus sql.NullString
|
||||||
|
var isCanceled sql.NullBool
|
||||||
|
var blockingCode, assetID, taskID sql.NullString
|
||||||
|
var confirmedAt, createdAt, updatedAt string
|
||||||
|
var sourceChanged bool
|
||||||
|
err := scanner.Scan(
|
||||||
|
&request.ID,
|
||||||
|
&request.CreatorSubject,
|
||||||
|
&request.FreightOrderItemID,
|
||||||
|
&request.SourceRevision,
|
||||||
|
&request.SourceSHA256,
|
||||||
|
&request.Title,
|
||||||
|
&request.ProductSpec,
|
||||||
|
&request.SKU,
|
||||||
|
&quantity,
|
||||||
|
&purchaseStatus,
|
||||||
|
&isCanceled,
|
||||||
|
&request.ProcurementConfirmedByUserID,
|
||||||
|
&confirmedAt,
|
||||||
|
&request.Status,
|
||||||
|
&blockingCode,
|
||||||
|
&assetID,
|
||||||
|
&taskID,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
&sourceChanged,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, err
|
||||||
|
}
|
||||||
|
if quantity.Valid {
|
||||||
|
value := int(quantity.Int64)
|
||||||
|
request.Quantity = &value
|
||||||
|
}
|
||||||
|
request.SourcePurchaseStatus = optionalString(purchaseStatus)
|
||||||
|
if isCanceled.Valid {
|
||||||
|
request.SourceIsCanceled = &isCanceled.Bool
|
||||||
|
}
|
||||||
|
request.BlockingCode = optionalString(blockingCode)
|
||||||
|
request.ReferenceAssetID = optionalString(assetID)
|
||||||
|
request.PurchaseTaskID = optionalString(taskID)
|
||||||
|
request.SourceChanged = sourceChanged
|
||||||
|
request.ProcurementConfirmedAt, err = parseTimestamp(confirmedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, err
|
||||||
|
}
|
||||||
|
request.CreatedAt, err = parseTimestamp(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ProcurementRequest{}, err
|
||||||
|
}
|
||||||
|
request.UpdatedAt, err = parseTimestamp(updatedAt)
|
||||||
|
return request, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func markProcurementSourceChanged(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
requestID string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
_, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE procurement_requests
|
||||||
|
SET status = 'SOURCE_CHANGED', blocking_code = 'SOURCE_CHANGED',
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ? AND purchase_task_id IS NULL`,
|
||||||
|
formatTimestamp(now),
|
||||||
|
requestID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.ProcurementRepository = (*Store)(nil)
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package sqlite_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/platform/migration"
|
||||||
|
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
db := openDatabase(t)
|
||||||
|
store, _ := repository.New(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Date(2026, 7, 28, 5, 6, 7, 0, time.UTC)
|
||||||
|
userID := uuid(1100)
|
||||||
|
seedFreightUser(t, db, userID, now)
|
||||||
|
run := freightRun(1101, userID, now)
|
||||||
|
createAndStartFreightRun(t, store, run, "procurement-source-1", "1")
|
||||||
|
if err := store.CompleteFreightSync(
|
||||||
|
ctx,
|
||||||
|
run,
|
||||||
|
freightBatch(1110, "a", "b"),
|
||||||
|
now.Add(time.Second),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||||
|
}
|
||||||
|
orders, _ := store.ListFreightOrders(ctx, "local-admin", 10)
|
||||||
|
detail, _ := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
||||||
|
service, err := usecase.NewProcurementService(
|
||||||
|
store,
|
||||||
|
procurementClock{now: now.Add(2 * time.Second)},
|
||||||
|
&procurementIDs{next: 1200},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewProcurementService() error = %v", err)
|
||||||
|
}
|
||||||
|
requests := make([]domain.ProcurementRequest, 0, len(detail.Items))
|
||||||
|
for _, item := range detail.Items {
|
||||||
|
result, err := service.CreateRequest(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementRequestCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
FreightOrderItemID: item.ID,
|
||||||
|
ConfirmProcurementNeeded: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || result.Request.Status !=
|
||||||
|
domain.ProcurementNeedsImage {
|
||||||
|
t.Fatalf("CreateRequest() = %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
requests = append(requests, result.Request)
|
||||||
|
}
|
||||||
|
if len(requests) != 2 || requests[0].ID == requests[1].ID {
|
||||||
|
t.Fatalf("requests = %+v", requests)
|
||||||
|
}
|
||||||
|
replay, err := service.CreateRequest(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementRequestCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
FreightOrderItemID: detail.Items[0].ID,
|
||||||
|
ConfirmProcurementNeeded: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || !replay.Replayed ||
|
||||||
|
replay.Request.ID != requests[0].ID {
|
||||||
|
t.Fatalf("request replay = %+v, %v", replay, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asset := domain.Asset{
|
||||||
|
ID: uuid(1300),
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
Purpose: domain.AssetPurposeTaskReference,
|
||||||
|
MediaType: domain.NormalizedImageMediaType,
|
||||||
|
SizeBytes: 100,
|
||||||
|
SHA256: repeatHex("f"),
|
||||||
|
StorageKey: "procurement/reference.jpg",
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if _, _, err := store.CreateAssetIdempotent(
|
||||||
|
ctx,
|
||||||
|
asset,
|
||||||
|
"procurement-asset",
|
||||||
|
repeatHex("e"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("CreateAssetIdempotent() error = %v", err)
|
||||||
|
}
|
||||||
|
bound, err := service.BindReference(
|
||||||
|
ctx,
|
||||||
|
usecase.BindProcurementReferenceCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: requests[0].ID,
|
||||||
|
ImageAssetID: asset.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || bound.Status != domain.ProcurementReady {
|
||||||
|
t.Fatalf("BindReference() = %+v, %v", bound, err)
|
||||||
|
}
|
||||||
|
created, err := service.CreateTask(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementTaskCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: requests[0].ID,
|
||||||
|
IdempotencyKey: "procurement-task-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || created.Task.Status != domain.TaskStatusPending ||
|
||||||
|
created.Task.Title != detail.Items[0].Title ||
|
||||||
|
created.Task.SKU != detail.Items[0].SKU ||
|
||||||
|
created.Task.ImageAssetID != asset.ID {
|
||||||
|
t.Fatalf("CreateTask() = %+v, %v", created, err)
|
||||||
|
}
|
||||||
|
replayedTask, err := service.CreateTask(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementTaskCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: requests[0].ID,
|
||||||
|
IdempotencyKey: "procurement-task-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || !replayedTask.Replayed ||
|
||||||
|
replayedTask.Task.ID != created.Task.ID {
|
||||||
|
t.Fatalf("task replay = %+v, %v", replayedTask, err)
|
||||||
|
}
|
||||||
|
buyerID := uuid(1600)
|
||||||
|
deviceID := uuid(1601)
|
||||||
|
seedLifecycleUser(
|
||||||
|
t,
|
||||||
|
db,
|
||||||
|
buyerID,
|
||||||
|
"procurement-buyer",
|
||||||
|
domain.UserRoleBuyer,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
seedLifecycleDevice(
|
||||||
|
t,
|
||||||
|
db,
|
||||||
|
deviceID,
|
||||||
|
"procurement-device",
|
||||||
|
buyerID,
|
||||||
|
repeatHex("6"),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
lifecycle := &lifecycleFixture{
|
||||||
|
store: store,
|
||||||
|
db: db,
|
||||||
|
buyerOneID: buyerID,
|
||||||
|
deviceOneID: deviceID,
|
||||||
|
}
|
||||||
|
readyAt := now.Add(3 * time.Second)
|
||||||
|
recordReadyHeartbeat(t, lifecycle, buyerID, deviceID, readyAt)
|
||||||
|
claimed, err := store.ClaimNext(
|
||||||
|
ctx,
|
||||||
|
lifecycleClaimRequest(
|
||||||
|
lifecycle,
|
||||||
|
buyerID,
|
||||||
|
deviceID,
|
||||||
|
1602,
|
||||||
|
readyAt.Add(time.Second),
|
||||||
|
readyAt.Add(10*time.Minute),
|
||||||
|
readyAt.Add(-time.Minute),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil || claimed.Task == nil ||
|
||||||
|
claimed.Task.ID != created.Task.ID ||
|
||||||
|
claimed.Task.Status != domain.TaskStatusClaimed {
|
||||||
|
t.Fatalf("Roubao claim = %+v, %v", claimed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRun := freightRun(1102, userID, now.Add(time.Minute))
|
||||||
|
createAndStartFreightRun(
|
||||||
|
t,
|
||||||
|
store,
|
||||||
|
updateRun,
|
||||||
|
"procurement-source-2",
|
||||||
|
"2",
|
||||||
|
)
|
||||||
|
changed := freightBatch(1120, "c", "b")
|
||||||
|
changed.Orders[0].CanonicalSHA256 = repeatHex("d")
|
||||||
|
changed.Orders[0].Items[0].Title = "ERP 更新后的标题"
|
||||||
|
changed.Orders[0].Items[0].CanonicalSHA256 = repeatHex("c")
|
||||||
|
if err := store.CompleteFreightSync(
|
||||||
|
ctx,
|
||||||
|
updateRun,
|
||||||
|
changed,
|
||||||
|
now.Add(time.Minute+time.Second),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("updated freight import error = %v", err)
|
||||||
|
}
|
||||||
|
storedRequest, err := service.Get(
|
||||||
|
ctx,
|
||||||
|
"local-admin",
|
||||||
|
requests[0].ID,
|
||||||
|
)
|
||||||
|
if err != nil || !storedRequest.SourceChanged ||
|
||||||
|
storedRequest.PurchaseTaskID == nil ||
|
||||||
|
*storedRequest.PurchaseTaskID != created.Task.ID {
|
||||||
|
t.Fatalf("source changed request = %+v, %v", storedRequest, err)
|
||||||
|
}
|
||||||
|
replayAfterUpdate, err := service.CreateTask(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementTaskCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: requests[0].ID,
|
||||||
|
IdempotencyKey: "procurement-task-after-source-update",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || !replayAfterUpdate.Replayed ||
|
||||||
|
replayAfterUpdate.Task.ID != created.Task.ID {
|
||||||
|
t.Fatalf(
|
||||||
|
"task replay after source update = %+v, %v",
|
||||||
|
replayAfterUpdate,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
storedTask, err := store.GetTaskDetail(
|
||||||
|
ctx,
|
||||||
|
"local-admin",
|
||||||
|
created.Task.ID,
|
||||||
|
)
|
||||||
|
if err != nil || storedTask.Task.Title != detail.Items[0].Title ||
|
||||||
|
storedTask.Task.Title == "ERP 更新后的标题" {
|
||||||
|
t.Fatalf("immutable task = %+v, %v", storedTask.Task, err)
|
||||||
|
}
|
||||||
|
runner, err := migration.New(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := runner.Down(ctx); err == nil {
|
||||||
|
t.Fatal("procurement migration down succeeded with retained requests")
|
||||||
|
}
|
||||||
|
var retained int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT count(*) FROM procurement_requests`,
|
||||||
|
).Scan(&retained); err != nil || retained != 2 {
|
||||||
|
t.Fatalf("retained requests = %d, error = %v", retained, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcurementBlocksInvalidSourceAndChangedRequest(t *testing.T) {
|
||||||
|
db := openDatabase(t)
|
||||||
|
store, _ := repository.New(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Date(2026, 7, 28, 6, 7, 8, 0, time.UTC)
|
||||||
|
userID := uuid(1400)
|
||||||
|
seedFreightUser(t, db, userID, now)
|
||||||
|
run := freightRun(1401, userID, now)
|
||||||
|
createAndStartFreightRun(t, store, run, "blocked-source-1", "3")
|
||||||
|
batch := freightBatch(1410, "a", "b")
|
||||||
|
batch.Orders[0].Items[0].SKU = ""
|
||||||
|
batch.Orders[0].Items[0].CanonicalSHA256 = repeatHex("7")
|
||||||
|
batch.Orders[0].CanonicalSHA256 = repeatHex("8")
|
||||||
|
if err := store.CompleteFreightSync(
|
||||||
|
ctx,
|
||||||
|
run,
|
||||||
|
batch,
|
||||||
|
now.Add(time.Second),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||||
|
}
|
||||||
|
orders, _ := store.ListFreightOrders(ctx, "local-admin", 10)
|
||||||
|
detail, _ := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
||||||
|
service, _ := usecase.NewProcurementService(
|
||||||
|
store,
|
||||||
|
procurementClock{now: now.Add(2 * time.Second)},
|
||||||
|
&procurementIDs{next: 1500},
|
||||||
|
)
|
||||||
|
blocked, err := service.CreateRequest(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementRequestCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
FreightOrderItemID: detail.Items[0].ID,
|
||||||
|
ConfirmProcurementNeeded: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil || blocked.Request.Status != domain.ProcurementBlocked ||
|
||||||
|
blocked.Request.BlockingCode == nil ||
|
||||||
|
*blocked.Request.BlockingCode != domain.ProcurementBlockSKURequired {
|
||||||
|
t.Fatalf("blocked request = %+v, %v", blocked, err)
|
||||||
|
}
|
||||||
|
_, err = service.CreateTask(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementTaskCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: blocked.Request.ID,
|
||||||
|
IdempotencyKey: "blocked-task",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
var typed *usecase.Error
|
||||||
|
if !errors.As(err, &typed) ||
|
||||||
|
typed.Code != "PROCUREMENT_STATE_CONFLICT" {
|
||||||
|
t.Fatalf("blocked CreateTask() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type procurementClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (clock procurementClock) Now() time.Time {
|
||||||
|
return clock.now
|
||||||
|
}
|
||||||
|
|
||||||
|
type procurementIDs struct {
|
||||||
|
next int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ids *procurementIDs) NewID() (string, error) {
|
||||||
|
ids.next++
|
||||||
|
return uuid(ids.next), nil
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type AdminServices struct {
|
|||||||
Results *usecase.ExecutionResultService
|
Results *usecase.ExecutionResultService
|
||||||
Authorizations *usecase.OrderAuthorizationService
|
Authorizations *usecase.OrderAuthorizationService
|
||||||
Freight *usecase.FreightService
|
Freight *usecase.FreightService
|
||||||
|
Procurement *usecase.ProcurementService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AdminServices) validate() error {
|
func (s AdminServices) validate() error {
|
||||||
@@ -68,6 +69,20 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
|||||||
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
||||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||||
}
|
}
|
||||||
|
if services.Procurement != nil {
|
||||||
|
routes.POST(
|
||||||
|
"/api/v1/freight-items/:id/procurement-request",
|
||||||
|
handler.createProcurementRequest,
|
||||||
|
)
|
||||||
|
routes.PUT(
|
||||||
|
"/api/v1/procurement-requests/:id/reference-asset",
|
||||||
|
handler.bindProcurementReference,
|
||||||
|
)
|
||||||
|
routes.POST(
|
||||||
|
"/api/v1/procurement-requests/:id/purchase-task",
|
||||||
|
handler.createProcurementTask,
|
||||||
|
)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,199 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||||
|
fixture := newAdminIntegrationFixture(t)
|
||||||
|
createSync := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/freight-syncs",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(
|
||||||
|
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
|
||||||
|
),
|
||||||
|
"procurement-freight-sync",
|
||||||
|
)
|
||||||
|
var syncBody struct {
|
||||||
|
Sync struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"sync"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, createSync, &syncBody)
|
||||||
|
succeeded := false
|
||||||
|
for attempt := 0; attempt < 50; attempt++ {
|
||||||
|
status := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/freight-syncs/"+syncBody.Sync.ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if strings.Contains(status.Body.String(), `"status":"SUCCEEDED"`) {
|
||||||
|
succeeded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !succeeded {
|
||||||
|
t.Fatal("freight sync did not succeed")
|
||||||
|
}
|
||||||
|
orders := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/freight-orders",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
var orderBody struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, orders, &orderBody)
|
||||||
|
detail := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
var detailBody struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, detail, &detailBody)
|
||||||
|
createRequest := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/freight-items/"+detailBody.Items[0].ID+
|
||||||
|
"/procurement-request",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(`{"confirm_procurement_needed":true}`),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if createRequest.Code != http.StatusCreated ||
|
||||||
|
!strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) {
|
||||||
|
t.Fatalf(
|
||||||
|
"create request status/body = %d / %s",
|
||||||
|
createRequest.Code,
|
||||||
|
createRequest.Body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var requestBody struct {
|
||||||
|
Request struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"procurement_request"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, createRequest, &requestBody)
|
||||||
|
imageBody, imageContentType := referenceUpload(t, "procurement-image")
|
||||||
|
upload := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/assets",
|
||||||
|
imageContentType,
|
||||||
|
imageBody,
|
||||||
|
"procurement-image",
|
||||||
|
)
|
||||||
|
var assetBody struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, upload, &assetBody)
|
||||||
|
bind := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPut,
|
||||||
|
"/api/v1/procurement-requests/"+requestBody.Request.ID+
|
||||||
|
"/reference-asset",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(
|
||||||
|
fmt.Sprintf(`{"image_asset_id":%q}`, assetBody.ID),
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if bind.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(bind.Body.String(), `"status":"READY"`) {
|
||||||
|
t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body)
|
||||||
|
}
|
||||||
|
createTask := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/procurement-requests/"+requestBody.Request.ID+
|
||||||
|
"/purchase-task",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(`{}`),
|
||||||
|
"procurement-task-create",
|
||||||
|
)
|
||||||
|
if createTask.Code != http.StatusCreated ||
|
||||||
|
!strings.Contains(createTask.Body.String(), `"status":"PENDING"`) {
|
||||||
|
t.Fatalf(
|
||||||
|
"create task status/body = %d / %s",
|
||||||
|
createTask.Code,
|
||||||
|
createTask.Body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var taskBody struct {
|
||||||
|
Task struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"task"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, createTask, &taskBody)
|
||||||
|
taskDetail := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/tasks/"+taskBody.Task.ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
for _, required := range []string{
|
||||||
|
`"title":"商品一"`,
|
||||||
|
`"sku":"BLACK-L"`,
|
||||||
|
`"quantity":1`,
|
||||||
|
`"image_asset_id":"` + assetBody.ID + `"`,
|
||||||
|
`"description":"ERP 规格:黑色,L"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(taskDetail.Body.String(), required) {
|
||||||
|
t.Fatalf("task detail missing %q: %s", required, taskDetail.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replay := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/procurement-requests/"+requestBody.Request.ID+
|
||||||
|
"/purchase-task",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(`{}`),
|
||||||
|
"procurement-task-replay",
|
||||||
|
)
|
||||||
|
if replay.Code != http.StatusCreated ||
|
||||||
|
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
|
||||||
|
!strings.Contains(replay.Body.String(), taskBody.Task.ID) {
|
||||||
|
t.Fatalf("task replay status/body = %d / %s", replay.Code, replay.Body)
|
||||||
|
}
|
||||||
|
var sourceCount int
|
||||||
|
if err := fixture.db.QueryRow(
|
||||||
|
`SELECT count(*) FROM purchase_task_sources
|
||||||
|
WHERE task_id = ? AND procurement_request_id = ?`,
|
||||||
|
taskBody.Task.ID,
|
||||||
|
requestBody.Request.ID,
|
||||||
|
).Scan(&sourceCount); err != nil || sourceCount != 1 {
|
||||||
|
t.Fatalf("task source count = %d, error = %v", sourceCount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||||
fixture := newAdminIntegrationFixture(t)
|
fixture := newAdminIntegrationFixture(t)
|
||||||
taskID, executionID, taskHash, firstKey, secondKey :=
|
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||||
@@ -515,6 +708,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("procurement migration down: %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("freight migration down: %v", err)
|
t.Fatalf("freight migration down: %v", err)
|
||||||
}
|
}
|
||||||
@@ -852,6 +1048,14 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("usecase.NewFreightService() error = %v", err)
|
t.Fatalf("usecase.NewFreightService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
procurement, err := usecase.NewProcurementService(
|
||||||
|
repositories,
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("usecase.NewProcurementService() error = %v", err)
|
||||||
|
}
|
||||||
registrar, err := NewAdminRouteRegistrar(
|
registrar, err := NewAdminRouteRegistrar(
|
||||||
AdminServices{
|
AdminServices{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
@@ -859,6 +1063,7 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
|||||||
Results: results,
|
Results: results,
|
||||||
Authorizations: authorizations,
|
Authorizations: authorizations,
|
||||||
Freight: freight,
|
Freight: freight,
|
||||||
|
Procurement: procurement,
|
||||||
},
|
},
|
||||||
emptyAdminWeb{},
|
emptyAdminWeb{},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() after review error = %v", err)
|
t.Fatalf("migration.New() after review error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("procurement migration down: %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("freight migration down: %v", err)
|
t.Fatalf("freight migration down: %v", err)
|
||||||
}
|
}
|
||||||
@@ -730,8 +733,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if applied, err := runner.Up(context.Background()); err != nil {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("restore device command migration: %v", err)
|
t.Fatalf("restore device command migration: %v", err)
|
||||||
} else if applied != 4 {
|
} else if applied != 5 {
|
||||||
t.Fatalf("restored migrations = %d, want 4", applied)
|
t.Fatalf("restored migrations = %d, want 5", applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
completePayload := fmt.Sprintf(
|
completePayload := fmt.Sprintf(
|
||||||
@@ -1420,6 +1423,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("procurement migration down: %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("freight migration down: %v", err)
|
t.Fatalf("freight migration down: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,10 +147,30 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
ctx.Header("Cache-Control", "no-store")
|
ctx.Header("Cache-Control", "no-store")
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
response := gin.H{
|
||||||
"order": freightOrderResponse(detail.Order),
|
"order": freightOrderResponse(detail.Order),
|
||||||
"items": items,
|
"items": items,
|
||||||
})
|
}
|
||||||
|
if h.services.Procurement != nil {
|
||||||
|
requests, requestErr := h.services.Procurement.ListForOrder(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
localAdminSubject,
|
||||||
|
detail.Order.ID,
|
||||||
|
)
|
||||||
|
if requestErr != nil {
|
||||||
|
writeUsecaseError(ctx, requestErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestItems := make([]gin.H, 0, len(requests))
|
||||||
|
for _, request := range requests {
|
||||||
|
requestItems = append(
|
||||||
|
requestItems,
|
||||||
|
procurementRequestResponse(request),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response["procurement_requests"] = requestItems
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
|
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *adminHandlers) createProcurementRequest(ctx *gin.Context) {
|
||||||
|
if !hasMediaType(ctx, "application/json") {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
"application/json is required",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
ConfirmProcurementNeeded bool `json:"confirm_procurement_needed"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(ctx, &request); err != nil {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"INVALID_JSON",
|
||||||
|
"request body must be valid JSON",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.services.Procurement.CreateRequest(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.CreateProcurementRequestCommand{
|
||||||
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: adminActorUserID(ctx),
|
||||||
|
FreightOrderItemID: ctx.Param("id"),
|
||||||
|
ConfirmProcurementNeeded: request.ConfirmProcurementNeeded,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.JSON(http.StatusCreated, gin.H{
|
||||||
|
"procurement_request": procurementRequestResponse(result.Request),
|
||||||
|
"replayed": result.Replayed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) bindProcurementReference(ctx *gin.Context) {
|
||||||
|
if !hasMediaType(ctx, "application/json") {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
"application/json is required",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
ImageAssetID string `json:"image_asset_id"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(ctx, &request); err != nil {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"INVALID_JSON",
|
||||||
|
"request body must be valid JSON",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.services.Procurement.BindReference(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.BindProcurementReferenceCommand{
|
||||||
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: adminActorUserID(ctx),
|
||||||
|
RequestID: ctx.Param("id"),
|
||||||
|
ImageAssetID: request.ImageAssetID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"procurement_request": procurementRequestResponse(result),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) createProcurementTask(ctx *gin.Context) {
|
||||||
|
if !hasMediaType(ctx, "application/json") {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusUnsupportedMediaType,
|
||||||
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
"application/json is required",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct{}
|
||||||
|
if err := decodeJSON(ctx, &request); err != nil {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"INVALID_JSON",
|
||||||
|
"request body must be an empty JSON object",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.services.Procurement.CreateTask(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.CreateProcurementTaskCommand{
|
||||||
|
CreatorSubject: localAdminSubject,
|
||||||
|
ActorUserID: adminActorUserID(ctx),
|
||||||
|
RequestID: ctx.Param("id"),
|
||||||
|
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.JSON(http.StatusCreated, gin.H{
|
||||||
|
"task": taskSummaryResponse(result.Task),
|
||||||
|
"replayed": result.Replayed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func procurementRequestResponse(
|
||||||
|
request domain.ProcurementRequest,
|
||||||
|
) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"id": request.ID,
|
||||||
|
"freight_order_item_id": request.FreightOrderItemID,
|
||||||
|
"source_revision": request.SourceRevision,
|
||||||
|
"source_sha256": request.SourceSHA256,
|
||||||
|
"title": request.Title,
|
||||||
|
"product_spec": request.ProductSpec,
|
||||||
|
"sku": request.SKU,
|
||||||
|
"quantity": request.Quantity,
|
||||||
|
"source_purchase_status": request.SourcePurchaseStatus,
|
||||||
|
"status": request.Status,
|
||||||
|
"blocking_code": request.BlockingCode,
|
||||||
|
"reference_asset_id": request.ReferenceAssetID,
|
||||||
|
"purchase_task_id": request.PurchaseTaskID,
|
||||||
|
"source_changed": request.SourceChanged,
|
||||||
|
"created_at": formatTime(request.CreatedAt),
|
||||||
|
"updated_at": formatTime(request.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,23 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
|||||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||||
}
|
}
|
||||||
|
if _, ok := h.service.(ProcurementService); ok {
|
||||||
|
routes.POST(
|
||||||
|
"/freight/items/:id/procurement-request",
|
||||||
|
SecurityHeaders(),
|
||||||
|
h.CreateFreightProcurementRequest,
|
||||||
|
)
|
||||||
|
routes.POST(
|
||||||
|
"/freight/procurement-requests/:id/reference",
|
||||||
|
SecurityHeaders(),
|
||||||
|
h.BindFreightProcurementReference,
|
||||||
|
)
|
||||||
|
routes.POST(
|
||||||
|
"/freight/procurement-requests/:id/purchase-task",
|
||||||
|
SecurityHeaders(),
|
||||||
|
h.CreateFreightProcurementTask,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListFreight(ctx *gin.Context) {
|
func (h *Handler) ListFreight(ctx *gin.Context) {
|
||||||
@@ -193,6 +210,23 @@ func (h *Handler) FreightDetail(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, _ := csrfToken(ctx)
|
token, _ := csrfToken(ctx)
|
||||||
|
for index := range detail.Items {
|
||||||
|
if detail.Items[index].Request == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uploadKey, keyErr := newToken()
|
||||||
|
if keyErr != nil {
|
||||||
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskKey, keyErr := newToken()
|
||||||
|
if keyErr != nil {
|
||||||
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail.Items[index].Request.UploadKey = uploadKey
|
||||||
|
detail.Items[index].Request.TaskKey = taskKey
|
||||||
|
}
|
||||||
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "货运详情",
|
Title: "货运详情",
|
||||||
@@ -200,9 +234,148 @@ func (h *Handler) FreightDetail(ctx *gin.Context) {
|
|||||||
CSRFToken: token,
|
CSRFToken: token,
|
||||||
},
|
},
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
|
Notice: freightNotice(ctx.Query("notice")),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateFreightProcurementRequest(ctx *gin.Context) {
|
||||||
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||||
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||||
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
||||||
|
if pathEscape(orderID) == "invalid" ||
|
||||||
|
ctx.PostForm("confirm_procurement_needed") != "1" {
|
||||||
|
h.renderError(ctx, http.StatusUnprocessableEntity, "必须人工确认", "请核对来源商品后确认仍需采购。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service := h.service.(ProcurementService)
|
||||||
|
_, err := service.CreateProcurementRequest(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
CreateProcurementRequestInput{
|
||||||
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||||
|
FreightOrderItemID: strings.TrimSpace(ctx.Param("id")),
|
||||||
|
ConfirmProcurementNeeded: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) {
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=request-conflict",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderServiceError(ctx, err, "采购需求创建失败,请稍后重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=request-created",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) BindFreightProcurementReference(ctx *gin.Context) {
|
||||||
|
ctx.Request.Body = http.MaxBytesReader(
|
||||||
|
ctx.Writer,
|
||||||
|
ctx.Request.Body,
|
||||||
|
maxRequestBytes,
|
||||||
|
)
|
||||||
|
if err := ctx.Request.ParseMultipartForm(maxRequestBytes); err != nil ||
|
||||||
|
!validCSRF(ctx) {
|
||||||
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Request.MultipartForm != nil {
|
||||||
|
defer ctx.Request.MultipartForm.RemoveAll()
|
||||||
|
}
|
||||||
|
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
||||||
|
uploadKey := strings.TrimSpace(ctx.PostForm("upload_key"))
|
||||||
|
if pathEscape(orderID) == "invalid" || !validToken(uploadKey) {
|
||||||
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asset, err := h.uploadReference(ctx, uploadKey)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=image-invalid",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service := h.service.(ProcurementService)
|
||||||
|
_, err = service.BindProcurementReference(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
BindProcurementReferenceInput{
|
||||||
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||||
|
RequestID: strings.TrimSpace(ctx.Param("id")),
|
||||||
|
ImageAssetID: asset.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=reference-conflict",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=reference-bound",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateFreightProcurementTask(ctx *gin.Context) {
|
||||||
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||||
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||||
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
||||||
|
taskKey := strings.TrimSpace(ctx.PostForm("task_key"))
|
||||||
|
if pathEscape(orderID) == "invalid" || !validToken(taskKey) {
|
||||||
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service := h.service.(ProcurementService)
|
||||||
|
task, err := service.CreateProcurementTask(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
CreateProcurementTaskInput{
|
||||||
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||||
|
RequestID: strings.TrimSpace(ctx.Param("id")),
|
||||||
|
IdempotencyKey: taskKey,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=task-conflict",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/tasks/"+pathEscape(task.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func freightNotice(value string) string {
|
||||||
|
switch value {
|
||||||
|
case "request-created":
|
||||||
|
return "采购需求已创建,请补充参考图。"
|
||||||
|
case "request-conflict":
|
||||||
|
return "来源已变化或当前商品不能创建采购需求。"
|
||||||
|
case "image-invalid":
|
||||||
|
return "参考图片无效,请选择 JPG、PNG 或 WebP 后重试。"
|
||||||
|
case "reference-conflict":
|
||||||
|
return "参考图已被使用或来源已变化,请刷新后重试。"
|
||||||
|
case "reference-bound":
|
||||||
|
return "参考图已绑定,可以生成采购任务。"
|
||||||
|
case "task-conflict":
|
||||||
|
return "需求状态或来源已变化,当前不能生成任务。"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func SecurityHeaders() gin.HandlerFunc {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
return func(ctx *gin.Context) {
|
return func(ctx *gin.Context) {
|
||||||
ctx.Header(
|
ctx.Header(
|
||||||
@@ -892,6 +1065,7 @@ type freightImportPage struct {
|
|||||||
type freightDetailPage struct {
|
type freightDetailPage struct {
|
||||||
Page pageView
|
Page pageView
|
||||||
Detail FreightOrderDetail
|
Detail FreightOrderDetail
|
||||||
|
Notice string
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusOption struct {
|
type statusOption struct {
|
||||||
|
|||||||
@@ -994,6 +994,83 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
|
||||||
|
const itemID = "00000000-0000-4000-8000-000000000002"
|
||||||
|
service := &fakeProcurementService{
|
||||||
|
fakeFreightService: &fakeFreightService{
|
||||||
|
fakeService: &fakeService{},
|
||||||
|
orderDetail: FreightOrderDetail{
|
||||||
|
Order: FreightOrder{
|
||||||
|
ID: testTaskID,
|
||||||
|
ExternalStockID: "12",
|
||||||
|
SourceCode: "SOURCE-12",
|
||||||
|
},
|
||||||
|
Items: []FreightItemReview{{
|
||||||
|
Item: FreightOrderItem{
|
||||||
|
ID: itemID,
|
||||||
|
ExternalItemID: "88",
|
||||||
|
Title: "商品",
|
||||||
|
SKU: "BLACK-L",
|
||||||
|
Quantity: 2,
|
||||||
|
},
|
||||||
|
Request: &ProcurementRequest{
|
||||||
|
ID: itemID,
|
||||||
|
FreightOrderItemID: itemID,
|
||||||
|
Status: "READY",
|
||||||
|
StatusLabel: "可以生成任务",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createTaskResult: Task{
|
||||||
|
ID: testTaskID,
|
||||||
|
Status: "PENDING",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := newTestRouter(t, service)
|
||||||
|
detail := performRequest(
|
||||||
|
t,
|
||||||
|
router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/freight/"+testTaskID,
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if detail.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(detail.Body.String(), "生成采购任务") ||
|
||||||
|
!strings.Contains(detail.Body.String(), "可以生成任务") {
|
||||||
|
t.Fatalf("detail status/body = %d / %s", detail.Code, detail.Body)
|
||||||
|
}
|
||||||
|
cookie := csrfCookie(t, detail)
|
||||||
|
taskKey := hiddenValue(t, detail.Body.String(), "task_key")
|
||||||
|
values := url.Values{
|
||||||
|
"csrf_token": {cookie.Value},
|
||||||
|
"order_id": {testTaskID},
|
||||||
|
"task_key": {taskKey},
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/freight/procurement-requests/"+itemID+"/purchase-task",
|
||||||
|
strings.NewReader(values.Encode()),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther ||
|
||||||
|
response.Header().Get("Location") != "/tasks/"+testTaskID {
|
||||||
|
t.Fatalf(
|
||||||
|
"create task status/location = %d / %q",
|
||||||
|
response.Code,
|
||||||
|
response.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if service.createTaskInput.RequestID != itemID ||
|
||||||
|
service.createTaskInput.IdempotencyKey != taskKey {
|
||||||
|
t.Fatalf("create task input = %+v", service.createTaskInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeService struct {
|
type fakeService struct {
|
||||||
listInput ListTasksInput
|
listInput ListTasksInput
|
||||||
listResult TaskList
|
listResult TaskList
|
||||||
@@ -1027,6 +1104,40 @@ type fakeFreightService struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeProcurementService struct {
|
||||||
|
*fakeFreightService
|
||||||
|
createRequestInput CreateProcurementRequestInput
|
||||||
|
bindInput BindProcurementReferenceInput
|
||||||
|
createTaskInput CreateProcurementTaskInput
|
||||||
|
procurementResult ProcurementRequest
|
||||||
|
createTaskResult Task
|
||||||
|
procurementError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *fakeProcurementService) CreateProcurementRequest(
|
||||||
|
_ context.Context,
|
||||||
|
input CreateProcurementRequestInput,
|
||||||
|
) (ProcurementRequest, error) {
|
||||||
|
service.createRequestInput = input
|
||||||
|
return service.procurementResult, service.procurementError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *fakeProcurementService) BindProcurementReference(
|
||||||
|
_ context.Context,
|
||||||
|
input BindProcurementReferenceInput,
|
||||||
|
) (ProcurementRequest, error) {
|
||||||
|
service.bindInput = input
|
||||||
|
return service.procurementResult, service.procurementError
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *fakeProcurementService) CreateProcurementTask(
|
||||||
|
_ context.Context,
|
||||||
|
input CreateProcurementTaskInput,
|
||||||
|
) (Task, error) {
|
||||||
|
service.createTaskInput = input
|
||||||
|
return service.createTaskResult, service.procurementError
|
||||||
|
}
|
||||||
|
|
||||||
func (service *fakeFreightService) ListFreightOrders(
|
func (service *fakeFreightService) ListFreightOrders(
|
||||||
context.Context,
|
context.Context,
|
||||||
int,
|
int,
|
||||||
|
|||||||
@@ -721,6 +721,35 @@ tbody tr:last-child td {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.procurement-actions {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.procurement-actions form {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.procurement-actions input[type="file"] {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-line input {
|
||||||
|
width: 20px;
|
||||||
|
min-height: 20px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-form {
|
.task-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<a class="button" href="/freight">返回列表</a>
|
<a class="button" href="/freight">返回列表</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
|
||||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||||
<h2 id="freight-source-title">来源信息</h2>
|
<h2 id="freight-source-title">来源信息</h2>
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid">
|
||||||
@@ -35,23 +36,73 @@
|
|||||||
<th scope="col">数量</th>
|
<th scope="col">数量</th>
|
||||||
<th scope="col">采购状态</th>
|
<th scope="col">采购状态</th>
|
||||||
<th scope="col">来源版本</th>
|
<th scope="col">来源版本</th>
|
||||||
|
<th scope="col">采购处理</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Detail.Items}}
|
{{range .Detail.Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="商品">
|
<td data-label="商品">
|
||||||
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
|
<strong>{{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}}</strong>
|
||||||
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
|
<span class="secondary">明细 ID:{{.Item.ExternalItemID}}</span>
|
||||||
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
|
{{if .Item.ProductThumbRef}}<span class="secondary">图片引用:{{.Item.ProductThumbRef}}</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="规格 / SKU">
|
<td data-label="规格 / SKU">
|
||||||
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
<span>{{if .Item.ProductSpec}}{{.Item.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
||||||
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
|
<span class="secondary">SKU:{{if .Item.SKU}}{{.Item.SKU}}{{else}}未提供{{end}}</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="数量">{{if .Item.Quantity}}{{.Item.Quantity}}{{else}}未提供{{end}}</td>
|
||||||
|
<td data-label="采购状态">{{if .Item.PurchaseStatus}}{{.Item.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
||||||
|
<td data-label="来源版本">{{.Item.Revision}}</td>
|
||||||
|
<td data-label="采购处理" class="procurement-actions">
|
||||||
|
{{if .Request}}
|
||||||
|
<strong>{{.Request.StatusLabel}}</strong>
|
||||||
|
{{if .Request.BlockingLabel}}<span class="secondary">{{.Request.BlockingLabel}}</span>{{end}}
|
||||||
|
{{if .Request.SourceChanged}}
|
||||||
|
<form method="post" action="/freight/items/{{pathPart .Item.ID}}/procurement-request">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||||
|
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||||
|
<label class="confirm-line">
|
||||||
|
<input type="checkbox" name="confirm_procurement_needed" value="1" required>
|
||||||
|
<span>已核对当前版本仍需采购</span>
|
||||||
|
</label>
|
||||||
|
<button class="button" type="submit">创建当前版本需求</button>
|
||||||
|
</form>
|
||||||
|
{{else if eq .Request.Status "NEEDS_IMAGE"}}
|
||||||
|
<form method="post" enctype="multipart/form-data"
|
||||||
|
action="/freight/procurement-requests/{{pathPart .Request.ID}}/reference">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||||
|
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||||
|
<input type="hidden" name="upload_key" value="{{.Request.UploadKey}}">
|
||||||
|
<label>
|
||||||
|
<span class="visually-hidden">参考图片</span>
|
||||||
|
<input name="image" type="file" accept="image/jpeg,image/png,image/webp" required>
|
||||||
|
</label>
|
||||||
|
<button class="button" type="submit">上传参考图</button>
|
||||||
|
</form>
|
||||||
|
{{else if eq .Request.Status "READY"}}
|
||||||
|
<form method="post"
|
||||||
|
action="/freight/procurement-requests/{{pathPart .Request.ID}}/purchase-task">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||||
|
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||||
|
<input type="hidden" name="task_key" value="{{.Request.TaskKey}}">
|
||||||
|
<button class="button primary" type="submit">生成采购任务</button>
|
||||||
|
</form>
|
||||||
|
{{else if .Request.PurchaseTaskID}}
|
||||||
|
<a class="detail-link" href="/tasks/{{pathPart .Request.PurchaseTaskID}}">查看采购任务</a>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/freight/items/{{pathPart .Item.ID}}/procurement-request">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
|
||||||
|
<input type="hidden" name="order_id" value="{{$.Detail.Order.ID}}">
|
||||||
|
<label class="confirm-line">
|
||||||
|
<input type="checkbox" name="confirm_procurement_needed" value="1" required>
|
||||||
|
<span>已核对该商品仍需采购</span>
|
||||||
|
</label>
|
||||||
|
<button class="button" type="submit">创建采购需求</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
|
|
||||||
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
|
||||||
<td data-label="来源版本">{{.Revision}}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -37,6 +37,39 @@ type FreightService interface {
|
|||||||
) (FreightSync, error)
|
) (FreightSync, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProcurementService interface {
|
||||||
|
CreateProcurementRequest(
|
||||||
|
context.Context,
|
||||||
|
CreateProcurementRequestInput,
|
||||||
|
) (ProcurementRequest, error)
|
||||||
|
BindProcurementReference(
|
||||||
|
context.Context,
|
||||||
|
BindProcurementReferenceInput,
|
||||||
|
) (ProcurementRequest, error)
|
||||||
|
CreateProcurementTask(
|
||||||
|
context.Context,
|
||||||
|
CreateProcurementTaskInput,
|
||||||
|
) (Task, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateProcurementRequestInput struct {
|
||||||
|
ActorUserID string
|
||||||
|
FreightOrderItemID string
|
||||||
|
ConfirmProcurementNeeded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type BindProcurementReferenceInput struct {
|
||||||
|
ActorUserID string
|
||||||
|
RequestID string
|
||||||
|
ImageAssetID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateProcurementTaskInput struct {
|
||||||
|
ActorUserID string
|
||||||
|
RequestID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
type FreightSync struct {
|
type FreightSync struct {
|
||||||
ID string
|
ID string
|
||||||
Status string
|
Status string
|
||||||
@@ -81,7 +114,27 @@ type FreightOrderItem struct {
|
|||||||
|
|
||||||
type FreightOrderDetail struct {
|
type FreightOrderDetail struct {
|
||||||
Order FreightOrder
|
Order FreightOrder
|
||||||
Items []FreightOrderItem
|
Items []FreightItemReview
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightItemReview struct {
|
||||||
|
Item FreightOrderItem
|
||||||
|
Request *ProcurementRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcurementRequest struct {
|
||||||
|
ID string
|
||||||
|
FreightOrderItemID string
|
||||||
|
SourceRevision int
|
||||||
|
Status string
|
||||||
|
StatusLabel string
|
||||||
|
BlockingCode string
|
||||||
|
BlockingLabel string
|
||||||
|
ReferenceAssetID string
|
||||||
|
PurchaseTaskID string
|
||||||
|
SourceChanged bool
|
||||||
|
UploadKey string
|
||||||
|
TaskKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTasksInput struct {
|
type ListTasksInput struct {
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ type UsecaseAdapter struct {
|
|||||||
assets *usecase.AssetService
|
assets *usecase.AssetService
|
||||||
authorizations *usecase.OrderAuthorizationService
|
authorizations *usecase.OrderAuthorizationService
|
||||||
freight *usecase.FreightService
|
freight *usecase.FreightService
|
||||||
|
procurement *usecase.ProcurementService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (adapter *UsecaseAdapter) SetProcurement(
|
||||||
|
procurement *usecase.ProcurementService,
|
||||||
|
) {
|
||||||
|
adapter.procurement = procurement
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUsecaseAdapter(
|
func NewUsecaseAdapter(
|
||||||
@@ -73,7 +80,23 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FreightOrderDetail{}, mapUsecaseError(err)
|
return FreightOrderDetail{}, mapUsecaseError(err)
|
||||||
}
|
}
|
||||||
items := make([]FreightOrderItem, 0, len(detail.Items))
|
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 {
|
for _, item := range detail.Items {
|
||||||
view := FreightOrderItem{
|
view := FreightOrderItem{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
@@ -88,7 +111,12 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
|||||||
if item.Quantity != nil {
|
if item.Quantity != nil {
|
||||||
view.Quantity = *item.Quantity
|
view.Quantity = *item.Quantity
|
||||||
}
|
}
|
||||||
items = append(items, view)
|
review := FreightItemReview{Item: view}
|
||||||
|
if request, exists := requestByItem[item.ID]; exists {
|
||||||
|
requestView := procurementRequestFrom(request)
|
||||||
|
review.Request = &requestView
|
||||||
|
}
|
||||||
|
items = append(items, review)
|
||||||
}
|
}
|
||||||
return FreightOrderDetail{
|
return FreightOrderDetail{
|
||||||
Order: freightOrderFrom(detail.Order),
|
Order: freightOrderFrom(detail.Order),
|
||||||
@@ -96,6 +124,131 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
|||||||
}, nil
|
}, 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(
|
func (adapter *UsecaseAdapter) GetFreightSync(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
syncID string,
|
syncID string,
|
||||||
@@ -660,3 +813,4 @@ func (err *adapterError) Unwrap() []error {
|
|||||||
|
|
||||||
var _ Service = (*UsecaseAdapter)(nil)
|
var _ Service = (*UsecaseAdapter)(nil)
|
||||||
var _ FreightService = (*UsecaseAdapter)(nil)
|
var _ FreightService = (*UsecaseAdapter)(nil)
|
||||||
|
var _ ProcurementService = (*UsecaseAdapter)(nil)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE procurement_requests (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
|
||||||
|
creator_subject TEXT NOT NULL,
|
||||||
|
freight_order_item_id TEXT NOT NULL
|
||||||
|
REFERENCES freight_order_items(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
|
||||||
|
source_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(source_sha256) = 64
|
||||||
|
AND source_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
title TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(title AS BLOB)) <= 2048),
|
||||||
|
product_spec TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(product_spec AS BLOB)) <= 1024),
|
||||||
|
sku TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(sku AS BLOB)) <= 512),
|
||||||
|
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
|
||||||
|
source_purchase_status TEXT
|
||||||
|
CHECK (
|
||||||
|
source_purchase_status IS NULL
|
||||||
|
OR length(CAST(source_purchase_status AS BLOB)) <= 128
|
||||||
|
),
|
||||||
|
source_is_canceled INTEGER
|
||||||
|
CHECK (
|
||||||
|
source_is_canceled IS NULL
|
||||||
|
OR source_is_canceled IN (0, 1)
|
||||||
|
),
|
||||||
|
procurement_confirmed_by_user_id TEXT NOT NULL
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
procurement_confirmed_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
status IN (
|
||||||
|
'BLOCKED',
|
||||||
|
'NEEDS_IMAGE',
|
||||||
|
'READY',
|
||||||
|
'TASK_CREATED',
|
||||||
|
'SOURCE_CHANGED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
blocking_code TEXT
|
||||||
|
CHECK (
|
||||||
|
blocking_code IS NULL
|
||||||
|
OR blocking_code IN (
|
||||||
|
'SOURCE_CANCELED',
|
||||||
|
'TITLE_REQUIRED',
|
||||||
|
'SKU_REQUIRED',
|
||||||
|
'QUANTITY_REQUIRED',
|
||||||
|
'SOURCE_CHANGED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
reference_asset_id TEXT UNIQUE
|
||||||
|
REFERENCES assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
purchase_task_id TEXT UNIQUE
|
||||||
|
REFERENCES purchase_tasks(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (freight_order_item_id, source_revision),
|
||||||
|
CHECK (
|
||||||
|
(status = 'BLOCKED'
|
||||||
|
AND blocking_code IS NOT NULL
|
||||||
|
AND reference_asset_id IS NULL
|
||||||
|
AND purchase_task_id IS NULL)
|
||||||
|
OR (status = 'NEEDS_IMAGE'
|
||||||
|
AND blocking_code IS NULL
|
||||||
|
AND reference_asset_id IS NULL
|
||||||
|
AND purchase_task_id IS NULL)
|
||||||
|
OR (status = 'READY'
|
||||||
|
AND blocking_code IS NULL
|
||||||
|
AND reference_asset_id IS NOT NULL
|
||||||
|
AND purchase_task_id IS NULL)
|
||||||
|
OR (status = 'TASK_CREATED'
|
||||||
|
AND blocking_code IS NULL
|
||||||
|
AND reference_asset_id IS NOT NULL
|
||||||
|
AND purchase_task_id IS NOT NULL)
|
||||||
|
OR (status = 'SOURCE_CHANGED'
|
||||||
|
AND blocking_code = 'SOURCE_CHANGED'
|
||||||
|
AND purchase_task_id IS NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX procurement_requests_creator_updated_idx
|
||||||
|
ON procurement_requests (creator_subject, updated_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX procurement_requests_item_created_idx
|
||||||
|
ON procurement_requests (
|
||||||
|
freight_order_item_id,
|
||||||
|
source_revision DESC,
|
||||||
|
id DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE purchase_task_sources (
|
||||||
|
task_id TEXT PRIMARY KEY NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
procurement_request_id TEXT NOT NULL UNIQUE
|
||||||
|
REFERENCES procurement_requests(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
freight_order_item_id TEXT NOT NULL
|
||||||
|
REFERENCES freight_order_items(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
source_revision INTEGER NOT NULL CHECK (source_revision >= 1),
|
||||||
|
source_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(source_sha256) = 64
|
||||||
|
AND source_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
CREATE TEMP TABLE procurement_v13_down_guard (
|
||||||
|
allowed INTEGER NOT NULL CHECK (allowed = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO procurement_v13_down_guard (allowed)
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM procurement_requests)
|
||||||
|
OR EXISTS (SELECT 1 FROM purchase_task_sources)
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TABLE procurement_v13_down_guard;
|
||||||
|
DROP TABLE purchase_task_sources;
|
||||||
|
DROP INDEX procurement_requests_item_created_idx;
|
||||||
|
DROP INDEX procurement_requests_creator_updated_idx;
|
||||||
|
DROP TABLE procurement_requests;
|
||||||
+28
-5
@@ -257,18 +257,41 @@ Connector 未登录、找不到货运单、响应协议错误和暂时不可用
|
|||||||
|
|
||||||
### `POST /api/v1/freight-items/{item_id}/procurement-request`
|
### `POST /api/v1/freight-items/{item_id}/procurement-request`
|
||||||
|
|
||||||
T-223 创建或重放该商品当前 revision 的采购需求。标题、SKU/规格或数量不合法时返回
|
T-223 创建或重放该商品当前 revision 的采购需求。ERP 数字状态不自动解释,ADMIN
|
||||||
`422 FREIGHT_ITEM_NOT_PURCHASABLE`;不调用 VLM 补字段。
|
必须显式确认仍需采购:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"confirm_procurement_needed":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求快照保留 title、product spec、SKU、quantity、来源 hash/revision 和当时的取消/
|
||||||
|
采购状态。缺标题、SKU、正整数数量或来源明确取消时创建为 `BLOCKED`,不调用 VLM
|
||||||
|
补字段;字段合格但没有受控参考图时为 `NEEDS_IMAGE`。
|
||||||
|
|
||||||
### `PUT /api/v1/procurement-requests/{id}/reference-asset`
|
### `PUT /api/v1/procurement-requests/{id}/reference-asset`
|
||||||
|
|
||||||
绑定当前 ADMIN 已上传的 `TASK_REFERENCE` asset。asset 必须尚未属于其他任务/请求,
|
绑定当前 ADMIN 已上传的 `TASK_REFERENCE` asset。asset 必须尚未属于其他任务/请求,
|
||||||
内容仍走既有解码、像素、规范化和 hash 校验。
|
内容仍走既有解码、像素、规范化和 hash 校验。请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"image_asset_id":"2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3"}
|
||||||
|
```
|
||||||
|
|
||||||
|
绑定成功后进入 `READY`。来源 revision/hash 或货运取消标记已变化时返回
|
||||||
|
`409 PROCUREMENT_SOURCE_CHANGED`。
|
||||||
|
|
||||||
### `POST /api/v1/procurement-requests/{id}/purchase-task`
|
### `POST /api/v1/procurement-requests/{id}/purchase-task`
|
||||||
|
|
||||||
必须带 `Idempotency-Key`。只有 `READY` request 可生成任务;同一 request revision
|
必须带 `Idempotency-Key`,请求体是空 JSON 对象 `{}`。只有 `READY` request 可生成
|
||||||
并发或重试返回同一 task。来源后续更新不修改已生成任务。
|
任务;task、`purchase_task_sources` 和 request 的 `TASK_CREATED` 状态在同一事务内
|
||||||
|
提交。同一 request revision 即使使用不同幂等 key 重试也返回同一 task。来源后续
|
||||||
|
更新只显示 `source_changed=true`,不修改 PENDING、CLAIMED、RUNNING 或终态任务。
|
||||||
|
|
||||||
|
管理 Web 在 `/freight/{id}` 内提供对应的人工确认、参考图上传和生成任务操作;写操作
|
||||||
|
分别使用 `/freight/items/{id}/procurement-request`、
|
||||||
|
`/freight/procurement-requests/{id}/reference` 和
|
||||||
|
`/freight/procurement-requests/{id}/purchase-task`,均受 ADMIN session 与 CSRF
|
||||||
|
保护。
|
||||||
|
|
||||||
## 采购任务
|
## 采购任务
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-28
|
- 日期:2026-07-28
|
||||||
- 阶段:T-222 已完成,T-223 待采购需求提取与任务生成已领取
|
- 阶段:T-223 已完成,T-224 ERP 日期增量同步已领取
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -17,14 +17,16 @@
|
|||||||
- ERP 货运:Go 后端通过仅限 loopback、服务密钥鉴权的 Python Connector 异步按
|
- ERP 货运:Go 后端通过仅限 loopback、服务密钥鉴权的 Python Connector 异步按
|
||||||
完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision,
|
完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision,
|
||||||
Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。
|
Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。
|
||||||
|
- 采购需求:v13 按货运商品 revision 保存可复核快照;ADMIN 显式确认采购、上传参考
|
||||||
|
图后生成不可变 PENDING 任务。来源变化不会改写任务,Roubao 可沿用现有 claim 流程。
|
||||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
||||||
- 后端测试:T-222 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
- 后端测试:T-223 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||||
覆盖货运 migration 降级保护、严格 Connector 字段白名单、重复导入/revision、
|
覆盖采购需求 migration 降级保护、一商品一需求、阻塞字段、图片唯一绑定、任务
|
||||||
中断恢复、Admin API/SSR 鉴权和 PII 缺失断言
|
并发重放、来源变化后任务不变、Roubao claim 和 Admin API/SSR
|
||||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||||
@@ -162,8 +164,8 @@
|
|||||||
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
|
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
|
||||||
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
|
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
|
||||||
| `docs/tasks/T-222.md` | DONE | 货运信息存储、API 与 Admin 页面 |
|
| `docs/tasks/T-222.md` | DONE | 货运信息存储、API 与 Admin 页面 |
|
||||||
| `docs/tasks/T-223.md` | DOING | 待采购需求提取与任务生成 |
|
| `docs/tasks/T-223.md` | DONE | 待采购需求提取与任务生成 |
|
||||||
| `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 |
|
| `docs/tasks/T-224.md` | DOING | ERP 日期增量同步 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -176,8 +178,8 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||||
- 已完成:另含 T-220 至 T-222 ERP 契约、Connector、货运存储/API/Admin。
|
- 已完成:另含 T-220 至 T-223 ERP 契约、Connector、货运存储和采购需求生成。
|
||||||
- 进行中:T-223 待采购需求提取与任务生成。
|
- 进行中:T-224 ERP 日期增量同步。
|
||||||
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
|
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
|
||||||
完整交互验收。
|
完整交互验收。
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -4,7 +4,7 @@ title: 待采购需求提取与任务生成
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-222
|
- T-222
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-28
|
created: 2026-07-28
|
||||||
context_ref: 78dc595
|
context_ref: 78dc595
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -50,11 +50,11 @@ Roubao 队列。
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] 一货运单多商品形成多条独立采购需求。
|
- [x] 一货运单多商品形成多条独立采购需求。
|
||||||
- [ ] 缺标题/SKU/数量/图片和来源状态不明时不能生成任务。
|
- [x] 缺标题/SKU/数量/图片、明确取消或未人工确认时不能生成任务。
|
||||||
- [ ] 同一 revision 并发/重试只生成一个采购任务。
|
- [x] 同一 revision 并发/重试只生成一个采购任务。
|
||||||
- [ ] 来源变化不覆写 PENDING/CLAIMED/RUNNING/终态任务。
|
- [x] 来源变化不覆写 PENDING/CLAIMED/RUNNING/终态任务。
|
||||||
- [ ] 生成任务可被现有 Roubao claim,原始字段和参考图 hash 一致。
|
- [x] 生成任务可被现有 Roubao claim,原始字段和参考图 hash 一致。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -65,3 +65,10 @@ Roubao 队列。
|
|||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-07-28:任务合约已冻结,等待 T-222。
|
- 2026-07-28:任务合约已冻结,等待 T-222。
|
||||||
|
- 2026-07-28:新增 v13 `procurement_requests` 与不可变
|
||||||
|
`purchase_task_sources`;来源数字状态不猜测,由 ADMIN 显式确认是否仍需采购。
|
||||||
|
- 2026-07-28:参考图绑定、任务和来源快照采用唯一约束与单事务;ERP 更新后旧任务
|
||||||
|
保持原始标题、SKU、数量和 asset,旧请求显示来源变化,当前 revision 可新建需求。
|
||||||
|
- 2026-07-28:真实 Gin/SQLite SSR 流程完成确认需求、上传规范化参考图、生成 PENDING
|
||||||
|
任务;同任务可由既有 Roubao claim。Playwright 三种视口无横向溢出、短按钮或页头
|
||||||
|
重叠;未调用 VLM 或真实 ERP。
|
||||||
|
|||||||
Reference in New Issue
Block a user