diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index fa5f7c0..dc73cc1 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -243,6 +243,10 @@ func buildRouter( if _, err := freight.RecoverInterrupted(ctx); err != nil { return nil, err } + procurement, err := usecase.NewProcurementService(store, clock, ids) + if err != nil { + return nil, err + } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err @@ -280,6 +284,7 @@ func buildRouter( if err != nil { return nil, err } + webService.SetProcurement(procurement) renderer, err := webui.NewRenderer() if err != nil { return nil, err @@ -322,6 +327,7 @@ func buildRouter( Results: results, Authorizations: authorizations, Freight: freight, + Procurement: procurement, }, webHandler, ) diff --git a/backend-api/internal/domain/procurement.go b/backend-api/internal/domain/procurement.go new file mode 100644 index 0000000..bf64f4e --- /dev/null +++ b/backend-api/internal/domain/procurement.go @@ -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 +} diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index 7a1dfb7..37a996a 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) - } else if applied != 12 { - t.Fatalf("initial Up() applied = %d, want 12", applied) + } else if applied != 13 { + 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 { t.Fatalf("initial Down(v12) error = %v", err) @@ -65,9 +68,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("Up(v5-v12) over historical data error = %v", err) - } else if applied != 8 { - t.Fatalf("Up(v5-v12) applied = %d, want 8", applied) + t.Fatalf("Up(v5-v13) over historical data error = %v", err) + } else if applied != 9 { + 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) @@ -117,9 +125,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v12) error = %v", err) - } else if applied != 9 { - t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied) + t.Fatalf("final Up(v4-v13) error = %v", err) + } else if applied != 10 { + t.Fatalf("final Up(v4-v13) applied = %d, want 10", applied) } assertClaimsHistory(t, db, true) } @@ -355,6 +363,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { 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 { t.Fatalf("Down(v12) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index c219091..4ad63b8 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 12 { - t.Fatalf("Up() applied = %d, want 12", applied) + if applied != 13 { + t.Fatalf("Up() applied = %d, want 13", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, @@ -43,6 +43,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 10: true, 11: true, 12: true, + 13: true, }) applied, err = runner.Up(context.Background()) @@ -68,7 +69,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 9: true, 10: true, 11: true, - 12: false, + 12: true, + 13: false, }) applied, err = runner.Up(context.Background()) @@ -91,6 +93,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 10: true, 11: true, 12: true, + 13: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 1e9bef1..9830d0f 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { 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 { t.Fatalf("Down(v12) error = %v", err) } @@ -426,9 +429,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3-v12) error = %v", err) - } else if applied != 10 { - t.Fatalf("Up(v3-v12) applied = %d, want 10", applied) + t.Fatalf("Up(v3-v13) error = %v", err) + } else if applied != 11 { + t.Fatalf("Up(v3-v13) applied = %d, want 11", applied) } } diff --git a/backend-api/internal/repository/sqlite/freight_repository_test.go b/backend-api/internal/repository/sqlite/freight_repository_test.go index d251bb7..84d4a15 100644 --- a/backend-api/internal/repository/sqlite/freight_repository_test.go +++ b/backend-api/internal/repository/sqlite/freight_repository_test.go @@ -117,6 +117,9 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { if err != nil { 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 { t.Fatal("freight migration down succeeded with retained source data") } diff --git a/backend-api/internal/repository/sqlite/procurement_repository.go b/backend-api/internal/repository/sqlite/procurement_repository.go new file mode 100644 index 0000000..d2215d5 --- /dev/null +++ b/backend-api/internal/repository/sqlite/procurement_repository.go @@ -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) diff --git a/backend-api/internal/repository/sqlite/procurement_repository_test.go b/backend-api/internal/repository/sqlite/procurement_repository_test.go new file mode 100644 index 0000000..df7bb99 --- /dev/null +++ b/backend-api/internal/repository/sqlite/procurement_repository_test.go @@ -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 +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index e24fe4d..1a3d00c 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -29,6 +29,7 @@ type AdminServices struct { Results *usecase.ExecutionResultService Authorizations *usecase.OrderAuthorizationService Freight *usecase.FreightService + Procurement *usecase.ProcurementService } 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/: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 } diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 443f484..723aa22 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -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) { fixture := newAdminIntegrationFixture(t) taskID, executionID, taskHash, firstKey, secondKey := @@ -515,6 +708,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) { if err != nil { 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 { t.Fatalf("freight migration down: %v", err) } @@ -852,6 +1048,14 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture { if err != nil { 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( AdminServices{ Assets: assets, @@ -859,6 +1063,7 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture { Results: results, Authorizations: authorizations, Freight: freight, + Procurement: procurement, }, emptyAdminWeb{}, ) diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 69e9454..a26d336 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) { if err != nil { 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 { 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 { t.Fatalf("restore device command migration: %v", err) - } else if applied != 4 { - t.Fatalf("restored migrations = %d, want 4", applied) + } else if applied != 5 { + t.Fatalf("restored migrations = %d, want 5", applied) } completePayload := fmt.Sprintf( @@ -1420,6 +1423,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( if err != nil { 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 { t.Fatalf("freight migration down: %v", err) } diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go index d0e3a8e..93972ef 100644 --- a/backend-api/internal/transport/httpapi/freight_handlers.go +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -147,10 +147,30 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) { }) } ctx.Header("Cache-Control", "no-store") - ctx.JSON(http.StatusOK, gin.H{ + response := gin.H{ "order": freightOrderResponse(detail.Order), "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 { diff --git a/backend-api/internal/transport/httpapi/procurement_handlers.go b/backend-api/internal/transport/httpapi/procurement_handlers.go new file mode 100644 index 0000000..6feed3c --- /dev/null +++ b/backend-api/internal/transport/httpapi/procurement_handlers.go @@ -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), + } +} diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index e3c6c3c..f38e2a4 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -77,6 +77,23 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) { routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport) 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) { @@ -193,6 +210,23 @@ func (h *Handler) FreightDetail(ctx *gin.Context) { return } 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{ Page: pageView{ Title: "货运详情", @@ -200,9 +234,148 @@ func (h *Handler) FreightDetail(ctx *gin.Context) { CSRFToken: token, }, 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 { return func(ctx *gin.Context) { ctx.Header( @@ -892,6 +1065,7 @@ type freightImportPage struct { type freightDetailPage struct { Page pageView Detail FreightOrderDetail + Notice string } type statusOption struct { diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index f86a549..90957f1 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -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 { listInput ListTasksInput listResult TaskList @@ -1027,6 +1104,40 @@ type fakeFreightService struct { 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( context.Context, int, diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index dc78496..4ec1d8b 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -721,6 +721,35 @@ tbody tr:last-child td { 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 { display: grid; gap: 18px; diff --git a/backend-api/internal/transport/webui/templates/freight-detail.gohtml b/backend-api/internal/transport/webui/templates/freight-detail.gohtml index 4de258d..fe6dd0c 100644 --- a/backend-api/internal/transport/webui/templates/freight-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-detail.gohtml @@ -15,6 +15,7 @@ 返回列表 + {{if .Notice}}