feat(t245): use ERP images as procurement references

This commit is contained in:
QiuSW
2026-07-29 17:29:20 +08:00
parent a20590b4b7
commit 15074692a3
22 changed files with 1057 additions and 117 deletions
+6 -1
View File
@@ -278,7 +278,12 @@ 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) procurement, err := usecase.NewProcurementService(
store,
clock,
ids,
usecase.WithProcurementReferenceStore(files),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -3,6 +3,7 @@ package domain
import "time" import "time"
type ProcurementRequestStatus string type ProcurementRequestStatus string
type ProcurementReferenceOrigin string
const ( const (
ProcurementBlocked ProcurementRequestStatus = "BLOCKED" ProcurementBlocked ProcurementRequestStatus = "BLOCKED"
@@ -20,6 +21,11 @@ const (
ProcurementBlockSourceChanged = "SOURCE_CHANGED" ProcurementBlockSourceChanged = "SOURCE_CHANGED"
) )
const (
ProcurementReferenceERP ProcurementReferenceOrigin = "ERP_FREIGHT_IMAGE"
ProcurementReferenceManual ProcurementReferenceOrigin = "MANUAL_UPLOAD"
)
type ProcurementSourceItem struct { type ProcurementSourceItem struct {
Item FreightOrderItem Item FreightOrderItem
IsCanceled *bool IsCanceled *bool
@@ -42,12 +48,23 @@ type ProcurementRequest struct {
Status ProcurementRequestStatus Status ProcurementRequestStatus
BlockingCode *string BlockingCode *string
ReferenceAssetID *string ReferenceAssetID *string
ReferenceOrigin *ProcurementReferenceOrigin
ReferenceProductThumbRef *string
ReferenceSourceImageSHA256 *string
ReferenceBoundAt *time.Time
PurchaseTaskID *string PurchaseTaskID *string
SourceChanged bool SourceChanged bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
type ProcurementReferenceSource struct {
Origin ProcurementReferenceOrigin
ProductThumbRef *string
SourceImageSHA256 *string
BoundAt time.Time
}
type PurchaseTaskSource struct { type PurchaseTaskSource struct {
TaskID string TaskID string
ProcurementRequestID string ProcurementRequestID string
@@ -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 != 16 { } else if applied != 17 {
t.Fatalf("initial Up() applied = %d, want 16", applied) t.Fatalf("initial Up() applied = %d, want 17", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v17) error = %v", err)
} }
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v16) error = %v", err) t.Fatalf("initial Down(v16) error = %v", err)
@@ -77,9 +80,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-v16) over historical data error = %v", err) t.Fatalf("Up(v5-v17) over historical data error = %v", err)
} else if applied != 12 { } else if applied != 13 {
t.Fatalf("Up(v5-v16) applied = %d, want 12", applied) t.Fatalf("Up(v5-v17) applied = %d, want 13", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v17) with compatible history error = %v", err)
} }
assertClaimsHistory(t, db, true) assertClaimsHistory(t, db, true)
@@ -149,9 +157,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-v16) error = %v", err) t.Fatalf("final Up(v4-v17) error = %v", err)
} else if applied != 13 { } else if applied != 14 {
t.Fatalf("final Up(v4-v16) applied = %d, want 13", applied) t.Fatalf("final Up(v4-v17) applied = %d, want 14", applied)
} }
assertClaimsHistory(t, db, true) assertClaimsHistory(t, db, true)
} }
@@ -387,6 +395,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(v17) error = %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v16) error = %v", err) t.Fatalf("Down(v16) 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 != 16 { if applied != 17 {
t.Fatalf("Up() applied = %d, want 16", applied) t.Fatalf("Up() applied = %d, want 17", applied)
} }
assertStatuses(t, runner, map[int64]bool{ assertStatuses(t, runner, map[int64]bool{
1: true, 1: true,
@@ -47,6 +47,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
14: true, 14: true,
15: true, 15: true,
16: true, 16: true,
17: true,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -76,7 +77,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
13: true, 13: true,
14: true, 14: true,
15: true, 15: true,
16: false, 16: true,
17: false,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -103,6 +105,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
14: true, 14: true,
15: true, 15: true,
16: true, 16: true,
17: 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(v17) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v16) error = %v", err) t.Fatalf("Down(v16) error = %v", err)
} }
@@ -439,8 +442,8 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
} }
if applied, err := runner.Up(context.Background()); err != nil { if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v16) error = %v", err) t.Fatalf("Up(v3-v16) error = %v", err)
} else if applied != 14 { } else if applied != 15 {
t.Fatalf("Up(v3-v16) applied = %d, want 14", applied) t.Fatalf("Up(v3-v17) applied = %d, want 15", applied)
} }
} }
@@ -189,6 +189,9 @@ func TestFreightItemImagesAreCurrentRetryableAndRollbackGuarded(
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("reference source migration down: %v", err)
}
if err := runner.Down(ctx); err == nil { if err := runner.Down(ctx); err == nil {
t.Fatal("image migration down succeeded with retained image metadata") t.Fatal("image migration down succeeded with retained image metadata")
} }
@@ -131,6 +131,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("reference source migration down: %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("image migration down: %v", err) t.Fatalf("image migration down: %v", err)
} }
@@ -271,6 +274,9 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
} }
runner, _ := migration.New(db) runner, _ := migration.New(db)
if err := runner.Down(ctx); err != nil {
t.Fatalf("reference source migration down: %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("image migration down: %v", err) t.Fatalf("image migration down: %v", err)
} }
@@ -0,0 +1,281 @@
package sqlite_test
import (
"bytes"
"context"
"image"
"image/jpeg"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/assetstore"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
)
func TestProcurementAutomaticallyCopiesAndCanReplaceERPReference(
t *testing.T,
) {
db := openDatabase(t)
repositories, _ := repository.New(db)
ctx := context.Background()
now := time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC)
userID := uuid(1700)
seedFreightUser(t, db, userID, now)
run := freightRun(1701, userID, now)
createAndStartFreightRun(t, repositories, run, "auto-reference", "1")
batch := freightBatch(1710, "a", "b")
firstThumb := "190100"
secondThumb := "190101"
batch.Orders[0].Items[0].ProductThumbRef = &firstThumb
batch.Orders[0].Items[1].ProductThumbRef = &secondThumb
if err := repositories.CompleteFreightSync(
ctx,
run,
batch,
now.Add(time.Second),
); err != nil {
t.Fatalf("CompleteFreightSync() error = %v", err)
}
orders, _ := repositories.ListFreightOrders(ctx, "local-admin", 10)
detail, _ := repositories.GetFreightOrder(
ctx,
"local-admin",
orders[0].ID,
)
files, err := assetstore.New(t.TempDir())
if err != nil {
t.Fatalf("assetstore.New() error = %v", err)
}
firstImage := saveReadyFreightImage(
t,
ctx,
repositories,
files,
detail.Items[0],
firstThumb,
now.Add(2*time.Second),
)
ids := &procurementIDs{next: 1800}
service, err := usecase.NewProcurementService(
repositories,
procurementClock{now: now.Add(3 * time.Second)},
ids,
usecase.WithProcurementReferenceStore(files),
)
if err != nil {
t.Fatalf("NewProcurementService() error = %v", err)
}
command := usecase.CreateProcurementRequestCommand{
CreatorSubject: "local-admin",
ActorUserID: userID,
FreightOrderItemID: detail.Items[0].ID,
ConfirmProcurementNeeded: true,
}
created, err := service.CreateRequest(ctx, command)
if err != nil ||
created.Request.Status != domain.ProcurementReady ||
created.Request.ReferenceAssetID == nil ||
created.Request.ReferenceOrigin == nil ||
*created.Request.ReferenceOrigin != domain.ProcurementReferenceERP ||
created.Request.ReferenceSourceImageSHA256 == nil ||
*created.Request.ReferenceSourceImageSHA256 != firstImage.SHA256 {
t.Fatalf("automatic request = %+v, %v", created, err)
}
autoAsset, err := repositories.GetAsset(
ctx,
"local-admin",
*created.Request.ReferenceAssetID,
)
if err != nil || autoAsset.StorageKey == firstImage.StorageKey {
t.Fatalf("automatic asset = %+v, %v", autoAsset, err)
}
assertStoredImageExists(t, ctx, files, firstImage.StorageKey)
assertStoredImageExists(t, ctx, files, autoAsset.StorageKey)
replayed, err := service.CreateRequest(ctx, command)
if err != nil || !replayed.Replayed ||
replayed.Request.ID != created.Request.ID ||
replayed.Request.ReferenceAssetID == nil ||
*replayed.Request.ReferenceAssetID != autoAsset.ID {
t.Fatalf("automatic replay = %+v, %v", replayed, err)
}
var assetCount int
if err := db.QueryRow(
`SELECT count(*) FROM assets WHERE creator_subject = ?`,
"local-admin",
).Scan(&assetCount); err != nil || assetCount != 1 {
t.Fatalf("asset count after replay = %d, %v", assetCount, err)
}
manualID := uuid(1900)
manualNormalized := putTestImage(t, ctx, files, manualID)
manual := domain.Asset{
ID: manualID,
CreatorSubject: "local-admin",
Purpose: domain.AssetPurposeTaskReference,
MediaType: manualNormalized.MediaType,
SizeBytes: manualNormalized.SizeBytes,
SHA256: manualNormalized.SHA256,
StorageKey: manualNormalized.StorageKey,
CreatedAt: now.Add(4 * time.Second),
}
if _, _, err := repositories.CreateAssetIdempotent(
ctx,
manual,
"manual-reference",
repeatHex("a"),
); err != nil {
t.Fatalf("CreateAssetIdempotent() error = %v", err)
}
replaced, err := service.BindReference(
ctx,
usecase.BindProcurementReferenceCommand{
CreatorSubject: "local-admin",
ActorUserID: userID,
RequestID: created.Request.ID,
ImageAssetID: manual.ID,
},
)
if err != nil ||
replaced.ReferenceOrigin == nil ||
*replaced.ReferenceOrigin != domain.ProcurementReferenceManual ||
replaced.ReferenceAssetID == nil ||
*replaced.ReferenceAssetID != manual.ID {
t.Fatalf("manual replacement = %+v, %v", replaced, err)
}
if _, err := repositories.GetAsset(
ctx,
"local-admin",
autoAsset.ID,
); err == nil {
t.Fatal("replaced automatic asset remains in database")
}
assertStoredImageMissing(t, ctx, files, autoAsset.StorageKey)
assertStoredImageExists(t, ctx, files, firstImage.StorageKey)
assertStoredImageExists(t, ctx, files, manual.StorageKey)
legacy, _ := usecase.NewProcurementService(
repositories,
procurementClock{now: now.Add(5 * time.Second)},
ids,
)
pending, err := legacy.CreateRequest(
ctx,
usecase.CreateProcurementRequestCommand{
CreatorSubject: "local-admin",
ActorUserID: userID,
FreightOrderItemID: detail.Items[1].ID,
ConfirmProcurementNeeded: true,
},
)
if err != nil || pending.Request.Status != domain.ProcurementNeedsImage {
t.Fatalf("pending request = %+v, %v", pending, err)
}
saveReadyFreightImage(
t,
ctx,
repositories,
files,
detail.Items[1],
secondThumb,
now.Add(6*time.Second),
)
upgraded, err := service.CreateRequest(
ctx,
usecase.CreateProcurementRequestCommand{
CreatorSubject: "local-admin",
ActorUserID: userID,
FreightOrderItemID: detail.Items[1].ID,
ConfirmProcurementNeeded: true,
},
)
if err != nil || !upgraded.Replayed ||
upgraded.Request.ID != pending.Request.ID ||
upgraded.Request.Status != domain.ProcurementReady {
t.Fatalf("upgraded request = %+v, %v", upgraded, err)
}
}
func saveReadyFreightImage(
t *testing.T,
ctx context.Context,
repositories *repository.Store,
files *assetstore.Store,
item domain.FreightOrderItem,
thumb string,
now time.Time,
) domain.FreightItemImage {
t.Helper()
normalized := putTestImage(t, ctx, files, item.ID)
candidate := domain.FreightItemImage{
CreatorSubject: "local-admin",
FreightOrderItemID: item.ID,
ProductThumbRef: thumb,
Status: domain.FreightItemImageReady,
MediaType: normalized.MediaType,
SizeBytes: normalized.SizeBytes,
SHA256: normalized.SHA256,
StorageKey: normalized.StorageKey,
UpdatedAt: now,
}
if _, err := repositories.SaveFreightItemImage(ctx, candidate); err != nil {
t.Fatalf("SaveFreightItemImage() error = %v", err)
}
return candidate
}
func putTestImage(
t *testing.T,
ctx context.Context,
files *assetstore.Store,
id string,
) usecase.NormalizedReferenceImage {
t.Helper()
var content bytes.Buffer
if err := jpeg.Encode(
&content,
image.NewRGBA(image.Rect(0, 0, 8, 6)),
nil,
); err != nil {
t.Fatalf("jpeg.Encode() error = %v", err)
}
normalized, err := files.Put(
ctx,
id,
"image/jpeg",
bytes.NewReader(content.Bytes()),
)
if err != nil {
t.Fatalf("files.Put() error = %v", err)
}
return normalized
}
func assertStoredImageExists(
t *testing.T,
ctx context.Context,
files *assetstore.Store,
key string,
) {
t.Helper()
content, err := files.Open(ctx, key)
if err != nil {
t.Fatalf("files.Open(%q) error = %v", key, err)
}
_ = content.Close()
}
func assertStoredImageMissing(
t *testing.T,
ctx context.Context,
files *assetstore.Store,
key string,
) {
t.Helper()
if content, err := files.Open(ctx, key); err == nil {
_ = content.Close()
t.Fatalf("files.Open(%q) succeeded after cleanup", key)
}
}
@@ -66,10 +66,12 @@ func (store *Store) GetProcurementSourceItem(
func (store *Store) CreateProcurementRequest( func (store *Store) CreateProcurementRequest(
ctx context.Context, ctx context.Context,
candidate domain.ProcurementRequest, candidate domain.ProcurementRequest,
) (domain.ProcurementRequest, bool, error) { auto *usecase.AutoProcurementReference,
) (domain.ProcurementRequest, bool, bool, error) {
tx, err := store.db.BeginTx(ctx, nil) tx, err := store.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return domain.ProcurementRequest{}, false, repositoryFailure(err) return domain.ProcurementRequest{}, false, false,
repositoryFailure(err)
} }
defer tx.Rollback() defer tx.Rollback()
existing, err := getProcurementRequestBySource( existing, err := getProcurementRequestBySource(
@@ -80,36 +82,60 @@ func (store *Store) CreateProcurementRequest(
candidate.SourceRevision, candidate.SourceRevision,
) )
if err == nil { if err == nil {
if err := tx.Commit(); err != nil { if auto != nil &&
return domain.ProcurementRequest{}, false, repositoryFailure(err) existing.Status == domain.ProcurementNeedsImage {
updated, bindErr := bindAutomaticProcurementReference(
ctx,
tx,
existing,
*auto,
candidate.UpdatedAt,
)
if bindErr != nil {
return domain.ProcurementRequest{}, false, false, bindErr
}
if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, false, false,
repositoryFailure(err)
}
return updated, false, true, nil
} }
return existing, false, nil if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, false, false,
repositoryFailure(err)
}
return existing, false, false, nil
} }
if !errors.Is(err, usecase.ErrRepositoryNotFound) { if !errors.Is(err, usecase.ErrRepositoryNotFound) {
return domain.ProcurementRequest{}, false, err return domain.ProcurementRequest{}, false, false, err
} }
var currentRevision int if err := validateProcurementSource(ctx, tx, candidate); err != nil {
var currentSHA string return domain.ProcurementRequest{}, false, false, err
var present bool }
if err := tx.QueryRowContext( referenceUsed := false
ctx, if auto != nil {
`SELECT item.revision, item.canonical_sha256, item.is_present if candidate.Status != domain.ProcurementNeedsImage {
FROM freight_order_items AS item return domain.ProcurementRequest{}, false, false,
JOIN freight_orders AS freight repositoryFailure(usecase.ErrRepositoryInvariant)
ON freight.id = item.freight_order_id
WHERE freight.creator_subject = ? AND item.id = ?`,
candidate.CreatorSubject,
candidate.FreightOrderItemID,
).Scan(&currentRevision, &currentSHA, &present); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ProcurementRequest{}, false, usecase.ErrRepositoryNotFound
} }
return domain.ProcurementRequest{}, false, repositoryFailure(err) if err := validateAutomaticProcurementReference(
} ctx,
if !present || currentRevision != candidate.SourceRevision || tx,
currentSHA != candidate.SourceSHA256 { candidate,
return domain.ProcurementRequest{}, false, *auto,
usecase.ErrProcurementSourceChanged ); err != nil {
return domain.ProcurementRequest{}, false, false, err
}
if err := insertProcurementReferenceAsset(
ctx,
tx,
auto.Asset,
); err != nil {
return domain.ProcurementRequest{}, false, false, err
}
candidate.ReferenceAssetID = &auto.Asset.ID
candidate.Status = domain.ProcurementReady
referenceUsed = true
} }
_, err = tx.ExecContext( _, err = tx.ExecContext(
ctx, ctx,
@@ -120,7 +146,7 @@ func (store *Store) CreateProcurementRequest(
procurement_confirmed_by_user_id, procurement_confirmed_at, procurement_confirmed_by_user_id, procurement_confirmed_at,
status, blocking_code, reference_asset_id, purchase_task_id, status, blocking_code, reference_asset_id, purchase_task_id,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
candidate.ID, candidate.ID,
candidate.CreatorSubject, candidate.CreatorSubject,
candidate.FreightOrderItemID, candidate.FreightOrderItemID,
@@ -136,16 +162,43 @@ func (store *Store) CreateProcurementRequest(
formatTimestamp(candidate.ProcurementConfirmedAt), formatTimestamp(candidate.ProcurementConfirmedAt),
candidate.Status, candidate.Status,
nullableString(candidate.BlockingCode), nullableString(candidate.BlockingCode),
nullableString(candidate.ReferenceAssetID),
formatTimestamp(candidate.CreatedAt), formatTimestamp(candidate.CreatedAt),
formatTimestamp(candidate.UpdatedAt), formatTimestamp(candidate.UpdatedAt),
) )
if err != nil { if err != nil {
return domain.ProcurementRequest{}, false, repositoryFailure(err) return domain.ProcurementRequest{}, false, false,
repositoryFailure(err)
}
if auto != nil {
if err := saveProcurementReferenceSource(
ctx,
tx,
candidate.ID,
domain.ProcurementReferenceSource{
Origin: domain.ProcurementReferenceERP,
ProductThumbRef: &auto.ProductThumbRef,
SourceImageSHA256: &auto.SourceImageSHA256,
BoundAt: candidate.UpdatedAt,
},
); err != nil {
return domain.ProcurementRequest{}, false, false, err
}
}
stored, err := getProcurementRequest(
ctx,
tx,
candidate.CreatorSubject,
candidate.ID,
)
if err != nil {
return domain.ProcurementRequest{}, false, false, err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, false, repositoryFailure(err) return domain.ProcurementRequest{}, false, false,
repositoryFailure(err)
} }
return candidate, true, nil return stored, true, referenceUsed, nil
} }
func (store *Store) ListProcurementRequestsForOrder( func (store *Store) ListProcurementRequestsForOrder(
@@ -190,10 +243,10 @@ func (store *Store) BindProcurementReference(
ctx context.Context, ctx context.Context,
creatorSubject, requestID, assetID string, creatorSubject, requestID, assetID string,
now time.Time, now time.Time,
) (domain.ProcurementRequest, error) { ) (domain.ProcurementRequest, *string, error) {
tx, err := store.db.BeginTx(ctx, nil) tx, err := store.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err) return domain.ProcurementRequest{}, nil, repositoryFailure(err)
} }
defer tx.Rollback() defer tx.Rollback()
request, err := getProcurementRequest( request, err := getProcurementRequest(
@@ -203,30 +256,33 @@ func (store *Store) BindProcurementReference(
requestID, requestID,
) )
if err != nil { if err != nil {
return domain.ProcurementRequest{}, err return domain.ProcurementRequest{}, nil, err
} }
if request.SourceChanged { if request.SourceChanged {
if request.PurchaseTaskID == nil { if request.PurchaseTaskID == nil {
if err := markProcurementSourceChanged(ctx, tx, request.ID, now); err != nil { if err := markProcurementSourceChanged(ctx, tx, request.ID, now); err != nil {
return domain.ProcurementRequest{}, err return domain.ProcurementRequest{}, nil, err
} }
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err) return domain.ProcurementRequest{}, nil, repositoryFailure(err)
} }
return domain.ProcurementRequest{}, usecase.ErrProcurementSourceChanged return domain.ProcurementRequest{}, nil,
usecase.ErrProcurementSourceChanged
} }
if request.ReferenceAssetID != nil && if request.ReferenceAssetID != nil &&
*request.ReferenceAssetID == assetID && *request.ReferenceAssetID == assetID &&
(request.Status == domain.ProcurementReady || (request.Status == domain.ProcurementReady ||
request.Status == domain.ProcurementTaskCreated) { request.Status == domain.ProcurementTaskCreated) {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err) return domain.ProcurementRequest{}, nil, repositoryFailure(err)
} }
return request, nil return request, nil, nil
} }
if request.Status != domain.ProcurementNeedsImage { if request.Status != domain.ProcurementNeedsImage &&
return domain.ProcurementRequest{}, usecase.ErrProcurementStateConflict request.Status != domain.ProcurementReady {
return domain.ProcurementRequest{}, nil,
usecase.ErrProcurementStateConflict
} }
var available int var available int
if err := tx.QueryRowContext( if err := tx.QueryRowContext(
@@ -250,30 +306,68 @@ func (store *Store) BindProcurementReference(
creatorSubject, creatorSubject,
requestID, requestID,
).Scan(&available); err != nil { ).Scan(&available); err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err) return domain.ProcurementRequest{}, nil, repositoryFailure(err)
} }
if available != 1 { if available != 1 {
return domain.ProcurementRequest{}, usecase.ErrAssetUnavailable return domain.ProcurementRequest{}, nil, usecase.ErrAssetUnavailable
}
var replacedStorageKey *string
var replacedAssetID *string
if request.ReferenceAssetID != nil &&
request.ReferenceOrigin != nil &&
*request.ReferenceOrigin == domain.ProcurementReferenceERP {
replaced, getErr := getAssetByID(
ctx,
tx,
creatorSubject,
*request.ReferenceAssetID,
)
if getErr != nil {
return domain.ProcurementRequest{}, nil, getErr
}
replacedStorageKey = &replaced.StorageKey
replacedAssetID = &replaced.ID
} }
if _, err := tx.ExecContext( if _, err := tx.ExecContext(
ctx, ctx,
`UPDATE procurement_requests `UPDATE procurement_requests
SET reference_asset_id = ?, status = 'READY', updated_at = ? SET reference_asset_id = ?, status = 'READY', blocking_code = NULL,
WHERE id = ? AND status = 'NEEDS_IMAGE'`, updated_at = ?
WHERE id = ? AND status IN ('NEEDS_IMAGE', 'READY')`,
assetID, assetID,
formatTimestamp(now), formatTimestamp(now),
requestID, requestID,
); err != nil { ); err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err) return domain.ProcurementRequest{}, nil, repositoryFailure(err)
}
if err := saveProcurementReferenceSource(
ctx,
tx,
requestID,
domain.ProcurementReferenceSource{
Origin: domain.ProcurementReferenceManual,
BoundAt: now,
},
); err != nil {
return domain.ProcurementRequest{}, nil, err
}
if replacedAssetID != nil {
if _, err := tx.ExecContext(
ctx,
`DELETE FROM assets WHERE id = ?`,
*replacedAssetID,
); err != nil {
return domain.ProcurementRequest{}, nil, repositoryFailure(err)
}
} }
updated, err := getProcurementRequest(ctx, tx, creatorSubject, requestID) updated, err := getProcurementRequest(ctx, tx, creatorSubject, requestID)
if err != nil { if err != nil {
return domain.ProcurementRequest{}, err return domain.ProcurementRequest{}, nil, err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err) return domain.ProcurementRequest{}, nil, repositoryFailure(err)
} }
return updated, nil return updated, replacedStorageKey, nil
} }
func (store *Store) CreateProcurementTask( func (store *Store) CreateProcurementTask(
@@ -488,6 +582,8 @@ const procurementRequestSelect = `SELECT
request.procurement_confirmed_by_user_id, request.procurement_confirmed_by_user_id,
request.procurement_confirmed_at, request.status, request.procurement_confirmed_at, request.status,
request.blocking_code, request.reference_asset_id, request.blocking_code, request.reference_asset_id,
reference_source.origin, reference_source.product_thumb_ref,
reference_source.source_image_sha256, reference_source.bound_at,
request.purchase_task_id, request.created_at, request.updated_at, request.purchase_task_id, request.created_at, request.updated_at,
CASE CASE
WHEN item.revision != request.source_revision WHEN item.revision != request.source_revision
@@ -502,6 +598,8 @@ const procurementRequestSelect = `SELECT
ON item.id = request.freight_order_item_id ON item.id = request.freight_order_item_id
JOIN freight_orders AS freight JOIN freight_orders AS freight
ON freight.id = item.freight_order_id ON freight.id = item.freight_order_id
LEFT JOIN procurement_reference_sources AS reference_source
ON reference_source.procurement_request_id = request.id
` `
func getProcurementRequest( func getProcurementRequest(
@@ -557,7 +655,9 @@ func scanProcurementRequest(
var quantity sql.NullInt64 var quantity sql.NullInt64
var purchaseStatus sql.NullString var purchaseStatus sql.NullString
var isCanceled sql.NullBool var isCanceled sql.NullBool
var blockingCode, assetID, taskID sql.NullString var blockingCode, assetID, referenceOrigin sql.NullString
var referenceThumb, referenceSHA, referenceBoundAt sql.NullString
var taskID sql.NullString
var confirmedAt, createdAt, updatedAt string var confirmedAt, createdAt, updatedAt string
var sourceChanged bool var sourceChanged bool
err := scanner.Scan( err := scanner.Scan(
@@ -577,6 +677,10 @@ func scanProcurementRequest(
&request.Status, &request.Status,
&blockingCode, &blockingCode,
&assetID, &assetID,
&referenceOrigin,
&referenceThumb,
&referenceSHA,
&referenceBoundAt,
&taskID, &taskID,
&createdAt, &createdAt,
&updatedAt, &updatedAt,
@@ -595,6 +699,19 @@ func scanProcurementRequest(
} }
request.BlockingCode = optionalString(blockingCode) request.BlockingCode = optionalString(blockingCode)
request.ReferenceAssetID = optionalString(assetID) request.ReferenceAssetID = optionalString(assetID)
if referenceOrigin.Valid {
origin := domain.ProcurementReferenceOrigin(referenceOrigin.String)
request.ReferenceOrigin = &origin
}
request.ReferenceProductThumbRef = optionalString(referenceThumb)
request.ReferenceSourceImageSHA256 = optionalString(referenceSHA)
if referenceBoundAt.Valid {
boundAt, parseErr := parseTimestamp(referenceBoundAt.String)
if parseErr != nil {
return domain.ProcurementRequest{}, parseErr
}
request.ReferenceBoundAt = &boundAt
}
request.PurchaseTaskID = optionalString(taskID) request.PurchaseTaskID = optionalString(taskID)
request.SourceChanged = sourceChanged request.SourceChanged = sourceChanged
request.ProcurementConfirmedAt, err = parseTimestamp(confirmedAt) request.ProcurementConfirmedAt, err = parseTimestamp(confirmedAt)
@@ -609,6 +726,204 @@ func scanProcurementRequest(
return request, err return request, err
} }
func validateProcurementSource(
ctx context.Context,
tx *sql.Tx,
candidate domain.ProcurementRequest,
) error {
var revision int
var sourceSHA string
var present bool
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(&revision, &sourceSHA, &present)
if errors.Is(err, sql.ErrNoRows) {
return usecase.ErrRepositoryNotFound
}
if err != nil {
return repositoryFailure(err)
}
if !present || revision != candidate.SourceRevision ||
sourceSHA != candidate.SourceSHA256 {
return usecase.ErrProcurementSourceChanged
}
return nil
}
func validateAutomaticProcurementReference(
ctx context.Context,
tx *sql.Tx,
candidate domain.ProcurementRequest,
auto usecase.AutoProcurementReference,
) error {
if auto.Asset.CreatorSubject != candidate.CreatorSubject ||
auto.Asset.Purpose != domain.AssetPurposeTaskReference ||
auto.Asset.MediaType != domain.NormalizedImageMediaType ||
auto.Asset.ID == "" || auto.Asset.StorageKey == "" ||
auto.Asset.SizeBytes <= 0 || len(auto.Asset.SHA256) != 64 {
return usecase.ErrRepositoryInvariant
}
var thumbRef, sourceSHA, storageKey string
err := tx.QueryRowContext(
ctx,
`SELECT image.product_thumb_ref, image.sha256, image.storage_key
FROM freight_item_images AS image
JOIN freight_order_items AS item
ON item.id = image.freight_order_item_id
JOIN freight_orders AS freight
ON freight.id = item.freight_order_id
WHERE freight.creator_subject = ?
AND item.id = ?
AND item.is_present = 1
AND item.product_thumb_ref = image.product_thumb_ref
AND image.status = 'READY'`,
candidate.CreatorSubject,
candidate.FreightOrderItemID,
).Scan(&thumbRef, &sourceSHA, &storageKey)
if errors.Is(err, sql.ErrNoRows) {
return usecase.ErrProcurementSourceChanged
}
if err != nil {
return repositoryFailure(err)
}
if thumbRef != auto.ProductThumbRef ||
sourceSHA != auto.SourceImageSHA256 ||
storageKey != auto.SourceImageStorageKey {
return usecase.ErrProcurementSourceChanged
}
return nil
}
func insertProcurementReferenceAsset(
ctx context.Context,
tx *sql.Tx,
asset domain.Asset,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO assets (
id, creator_subject, purpose, media_type, size_bytes,
sha256, storage_key, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
asset.ID,
asset.CreatorSubject,
asset.Purpose,
asset.MediaType,
asset.SizeBytes,
asset.SHA256,
asset.StorageKey,
formatTimestamp(asset.CreatedAt),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
func saveProcurementReferenceSource(
ctx context.Context,
tx *sql.Tx,
requestID string,
source domain.ProcurementReferenceSource,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO procurement_reference_sources (
procurement_request_id, origin, product_thumb_ref,
source_image_sha256, bound_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(procurement_request_id) DO UPDATE SET
origin = excluded.origin,
product_thumb_ref = excluded.product_thumb_ref,
source_image_sha256 = excluded.source_image_sha256,
bound_at = excluded.bound_at`,
requestID,
source.Origin,
nullableString(source.ProductThumbRef),
nullableString(source.SourceImageSHA256),
formatTimestamp(source.BoundAt),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
func bindAutomaticProcurementReference(
ctx context.Context,
tx *sql.Tx,
request domain.ProcurementRequest,
auto usecase.AutoProcurementReference,
now time.Time,
) (domain.ProcurementRequest, error) {
if request.SourceChanged {
return domain.ProcurementRequest{},
usecase.ErrProcurementSourceChanged
}
if err := validateProcurementSource(ctx, tx, request); err != nil {
return domain.ProcurementRequest{}, err
}
if err := validateAutomaticProcurementReference(
ctx,
tx,
request,
auto,
); err != nil {
return domain.ProcurementRequest{}, err
}
if err := insertProcurementReferenceAsset(ctx, tx, auto.Asset); err != nil {
return domain.ProcurementRequest{}, err
}
result, err := tx.ExecContext(
ctx,
`UPDATE procurement_requests
SET reference_asset_id = ?, status = 'READY',
blocking_code = NULL, updated_at = ?
WHERE id = ? AND status = 'NEEDS_IMAGE'
AND reference_asset_id IS NULL`,
auto.Asset.ID,
formatTimestamp(now),
request.ID,
)
if err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err)
}
affected, err := result.RowsAffected()
if err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err)
}
if affected != 1 {
return domain.ProcurementRequest{},
usecase.ErrProcurementStateConflict
}
if err := saveProcurementReferenceSource(
ctx,
tx,
request.ID,
domain.ProcurementReferenceSource{
Origin: domain.ProcurementReferenceERP,
ProductThumbRef: &auto.ProductThumbRef,
SourceImageSHA256: &auto.SourceImageSHA256,
BoundAt: now,
},
); err != nil {
return domain.ProcurementRequest{}, err
}
return getProcurementRequest(
ctx,
tx,
request.CreatorSubject,
request.ID,
)
}
func markProcurementSourceChanged( func markProcurementSourceChanged(
ctx context.Context, ctx context.Context,
tx *sql.Tx, tx *sql.Tx,
@@ -238,6 +238,17 @@ func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable(
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.Fatal("reference source migration down succeeded with retained provenance")
}
if _, err := db.Exec(
`DELETE FROM procurement_reference_sources`,
); err != nil {
t.Fatalf("delete reference provenance: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("reference source migration down: %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("image migration down: %v", err) t.Fatalf("image migration down: %v", err)
} }
@@ -766,7 +766,11 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
"", "",
) )
if createRequest.Code != http.StatusCreated || if createRequest.Code != http.StatusCreated ||
!strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) { !strings.Contains(createRequest.Body.String(), `"status":"READY"`) ||
!strings.Contains(
createRequest.Body.String(),
`"reference_origin":"ERP_FREIGHT_IMAGE"`,
) {
t.Fatalf( t.Fatalf(
"create request status/body = %d / %s", "create request status/body = %d / %s",
createRequest.Code, createRequest.Code,
@@ -806,7 +810,11 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
"", "",
) )
if bind.Code != http.StatusOK || if bind.Code != http.StatusOK ||
!strings.Contains(bind.Body.String(), `"status":"READY"`) { !strings.Contains(bind.Body.String(), `"status":"READY"`) ||
!strings.Contains(
bind.Body.String(),
`"reference_origin":"MANUAL_UPLOAD"`,
) {
t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body) t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body)
} }
deleteWithReference := performAdminRequest( deleteWithReference := performAdminRequest(
@@ -920,7 +928,7 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
} }
func TestAdminFreightDeleteRemovesNeedsImageDraft(t *testing.T) { func TestAdminFreightDeleteRemovesNeedsImageDraft(t *testing.T) {
fixture := newAdminIntegrationFixture(t) fixture := newAdminIntegrationFixtureWithReferences(t, false)
createSync := performAdminRequest( createSync := performAdminRequest(
t, t,
fixture.router, fixture.router,
@@ -1123,6 +1131,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("reference source migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("image migration down: %v", err) t.Fatalf("image migration down: %v", err)
} }
@@ -1406,6 +1417,13 @@ type adminIntegrationFixture struct {
} }
func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture { func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
return newAdminIntegrationFixtureWithReferences(t, true)
}
func newAdminIntegrationFixtureWithReferences(
t *testing.T,
autoReferences bool,
) *adminIntegrationFixture {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db")) db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
@@ -1482,10 +1500,18 @@ 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)
} }
procurementOptions := make([]usecase.ProcurementServiceOption, 0, 1)
if autoReferences {
procurementOptions = append(
procurementOptions,
usecase.WithProcurementReferenceStore(files),
)
}
procurement, err := usecase.NewProcurementService( procurement, err := usecase.NewProcurementService(
repositories, repositories,
clock, clock,
ids, ids,
procurementOptions...,
) )
if err != nil { if err != nil {
t.Fatalf("usecase.NewProcurementService() error = %v", err) t.Fatalf("usecase.NewProcurementService() error = %v", err)
@@ -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("reference source migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("image migration down: %v", err) t.Fatalf("image migration down: %v", err)
} }
@@ -742,8 +745,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 != 8 { } else if applied != 9 {
t.Fatalf("restored migrations = %d, want 8", applied) t.Fatalf("restored migrations = %d, want 9", applied)
} }
completePayload := fmt.Sprintf( completePayload := fmt.Sprintf(
@@ -1432,6 +1435,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("reference source migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("image migration down: %v", err) t.Fatalf("image migration down: %v", err)
} }
@@ -160,6 +160,7 @@ func procurementRequestResponse(
"status": request.Status, "status": request.Status,
"blocking_code": request.BlockingCode, "blocking_code": request.BlockingCode,
"reference_asset_id": request.ReferenceAssetID, "reference_asset_id": request.ReferenceAssetID,
"reference_origin": request.ReferenceOrigin,
"purchase_task_id": request.PurchaseTaskID, "purchase_task_id": request.PurchaseTaskID,
"source_changed": request.SourceChanged, "source_changed": request.SourceChanged,
"created_at": formatTime(request.CreatedAt), "created_at": formatTime(request.CreatedAt),
@@ -1239,10 +1239,12 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
ImageURL: "/api/v1/freight-items/" + itemID + "/image", ImageURL: "/api/v1/freight-items/" + itemID + "/image",
}, },
Request: &ProcurementRequest{ Request: &ProcurementRequest{
ID: itemID, ID: itemID,
FreightOrderItemID: itemID, FreightOrderItemID: itemID,
Status: "READY", Status: "READY",
StatusLabel: "可以生成任务", StatusLabel: "可以生成任务",
ReferenceOrigin: "ERP_FREIGHT_IMAGE",
ReferenceOriginLabel: "参考图来自 ERP 商品图",
}, },
}}, }},
}, },
@@ -1263,6 +1265,8 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
) )
if detail.Code != http.StatusOK || if detail.Code != http.StatusOK ||
!strings.Contains(detail.Body.String(), "生成采购任务") || !strings.Contains(detail.Body.String(), "生成采购任务") ||
!strings.Contains(detail.Body.String(), "更换参考图") ||
!strings.Contains(detail.Body.String(), "参考图来自 ERP 商品图") ||
!strings.Contains(detail.Body.String(), "可以生成任务") || !strings.Contains(detail.Body.String(), "可以生成任务") ||
!strings.Contains(detail.Body.String(), "TWD 129.50") || !strings.Contains(detail.Body.String(), "TWD 129.50") ||
!strings.Contains(detail.Body.String(), `width="84" height="84"`) || !strings.Contains(detail.Body.String(), `width="84" height="84"`) ||
@@ -1273,6 +1277,13 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
} }
cookie := csrfCookie(t, detail) cookie := csrfCookie(t, detail)
taskKey := hiddenValue(t, detail.Body.String(), "task_key") taskKey := hiddenValue(t, detail.Body.String(), "task_key")
if uploadKey := hiddenValue(
t,
detail.Body.String(),
"upload_key",
); !validToken(uploadKey) {
t.Fatalf("upload key = %q", uploadKey)
}
values := url.Values{ values := url.Values{
"csrf_token": {cookie.Value}, "csrf_token": {cookie.Value},
"order_id": {testTaskID}, "order_id": {testTaskID},
@@ -103,6 +103,7 @@
<button class="button" type="submit">上传参考图</button> <button class="button" type="submit">上传参考图</button>
</form> </form>
{{else if eq .Request.Status "READY"}} {{else if eq .Request.Status "READY"}}
{{if .Request.ReferenceOriginLabel}}<span class="secondary">{{.Request.ReferenceOriginLabel}}</span>{{end}}
<form method="post" <form method="post"
action="/freight/procurement-requests/{{pathPart .Request.ID}}/purchase-task"> action="/freight/procurement-requests/{{pathPart .Request.ID}}/purchase-task">
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
@@ -110,6 +111,17 @@
<input type="hidden" name="task_key" value="{{.Request.TaskKey}}"> <input type="hidden" name="task_key" value="{{.Request.TaskKey}}">
<button class="button primary" type="submit">生成采购任务</button> <button class="button primary" type="submit">生成采购任务</button>
</form> </form>
<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 .Request.PurchaseTaskID}} {{else if .Request.PurchaseTaskID}}
<a class="detail-link" href="/tasks/{{pathPart .Request.PurchaseTaskID}}">查看采购任务</a> <a class="detail-link" href="/tasks/{{pathPart .Request.PurchaseTaskID}}">查看采购任务</a>
{{end}} {{end}}
+14 -12
View File
@@ -192,18 +192,20 @@ type FreightItemReview struct {
} }
type ProcurementRequest struct { type ProcurementRequest struct {
ID string ID string
FreightOrderItemID string FreightOrderItemID string
SourceRevision int SourceRevision int
Status string Status string
StatusLabel string StatusLabel string
BlockingCode string BlockingCode string
BlockingLabel string BlockingLabel string
ReferenceAssetID string ReferenceAssetID string
PurchaseTaskID string ReferenceOrigin string
SourceChanged bool ReferenceOriginLabel string
UploadKey string PurchaseTaskID string
TaskKey string SourceChanged bool
UploadKey string
TaskKey string
} }
type ListTasksInput struct { type ListTasksInput struct {
@@ -292,9 +292,11 @@ func procurementRequestFrom(
BlockingLabel: procurementBlockingLabel( BlockingLabel: procurementBlockingLabel(
stringValue(request.BlockingCode), stringValue(request.BlockingCode),
), ),
ReferenceAssetID: stringValue(request.ReferenceAssetID), ReferenceAssetID: stringValue(request.ReferenceAssetID),
PurchaseTaskID: stringValue(request.PurchaseTaskID), ReferenceOrigin: procurementReferenceOrigin(request),
SourceChanged: request.SourceChanged, ReferenceOriginLabel: procurementReferenceOriginLabel(request),
PurchaseTaskID: stringValue(request.PurchaseTaskID),
SourceChanged: request.SourceChanged,
} }
if request.SourceChanged { if request.SourceChanged {
result.StatusLabel = "来源已变化" result.StatusLabel = "来源已变化"
@@ -302,6 +304,28 @@ func procurementRequestFrom(
return result return result
} }
func procurementReferenceOrigin(
request domain.ProcurementRequest,
) string {
if request.ReferenceOrigin == nil {
return ""
}
return string(*request.ReferenceOrigin)
}
func procurementReferenceOriginLabel(
request domain.ProcurementRequest,
) string {
switch procurementReferenceOrigin(request) {
case string(domain.ProcurementReferenceERP):
return "参考图来自 ERP 商品图"
case string(domain.ProcurementReferenceManual):
return "参考图由人工上传"
default:
return ""
}
}
func procurementStatusLabel( func procurementStatusLabel(
status domain.ProcurementRequestStatus, status domain.ProcurementRequestStatus,
) string { ) string {
@@ -7,16 +7,29 @@ import (
"cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/domain"
) )
type AutoProcurementReference struct {
Asset domain.Asset
ProductThumbRef string
SourceImageSHA256 string
SourceImageStorageKey string
}
type ProcurementRepository interface { type ProcurementRepository interface {
GetProcurementSourceItem( GetProcurementSourceItem(
context.Context, context.Context,
string, string,
string, string,
) (domain.ProcurementSourceItem, error) ) (domain.ProcurementSourceItem, error)
GetReadyFreightItemImage(
context.Context,
string,
string,
) (domain.FreightItemImage, error)
CreateProcurementRequest( CreateProcurementRequest(
context.Context, context.Context,
domain.ProcurementRequest, domain.ProcurementRequest,
) (domain.ProcurementRequest, bool, error) *AutoProcurementReference,
) (domain.ProcurementRequest, bool, bool, error)
ListProcurementRequestsForOrder( ListProcurementRequestsForOrder(
context.Context, context.Context,
string, string,
@@ -33,7 +46,7 @@ type ProcurementRepository interface {
string, string,
string, string,
time.Time, time.Time,
) (domain.ProcurementRequest, error) ) (domain.ProcurementRequest, *string, error)
CreateProcurementTask( CreateProcurementTask(
context.Context, context.Context,
domain.ProcurementRequest, domain.ProcurementRequest,
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
"cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/domain"
@@ -21,10 +22,25 @@ var (
type ProcurementService struct { type ProcurementService struct {
repository ProcurementRepository repository ProcurementRepository
store ReferenceImageStore
clock Clock clock Clock
ids IDGenerator ids IDGenerator
} }
type ProcurementServiceOption func(*ProcurementService) error
func WithProcurementReferenceStore(
store ReferenceImageStore,
) ProcurementServiceOption {
return func(service *ProcurementService) error {
if store == nil {
return errors.New("procurement reference store is required")
}
service.store = store
return nil
}
}
type CreateProcurementRequestCommand struct { type CreateProcurementRequestCommand struct {
CreatorSubject string CreatorSubject string
ActorUserID string ActorUserID string
@@ -60,15 +76,25 @@ func NewProcurementService(
repository ProcurementRepository, repository ProcurementRepository,
clock Clock, clock Clock,
ids IDGenerator, ids IDGenerator,
options ...ProcurementServiceOption,
) (*ProcurementService, error) { ) (*ProcurementService, error) {
if repository == nil || clock == nil || ids == nil { if repository == nil || clock == nil || ids == nil {
return nil, errors.New("procurement service dependencies are required") return nil, errors.New("procurement service dependencies are required")
} }
return &ProcurementService{ service := &ProcurementService{
repository: repository, repository: repository,
clock: clock, clock: clock,
ids: ids, ids: ids,
}, nil }
for _, option := range options {
if option == nil {
return nil, errors.New("procurement service option is required")
}
if err := option(service); err != nil {
return nil, err
}
}
return service, nil
} }
func (service *ProcurementService) CreateRequest( func (service *ProcurementService) CreateRequest(
@@ -136,13 +162,27 @@ func (service *ProcurementService) CreateRequest(
request.Status = domain.ProcurementBlocked request.Status = domain.ProcurementBlocked
request.BlockingCode = &code request.BlockingCode = &code
} }
stored, created, err := service.repository.CreateProcurementRequest( var auto *AutoProcurementReference
ctx, if request.Status == domain.ProcurementNeedsImage &&
request, service.store != nil {
) auto, err = service.copyFreightImageReference(ctx, request, now)
if err != nil {
return CreateProcurementRequestResult{}, err
}
}
stored, created, referenceUsed, err :=
service.repository.CreateProcurementRequest(
ctx,
request,
auto,
)
if err != nil { if err != nil {
service.cleanupAutomaticReference(auto)
return CreateProcurementRequestResult{}, wrapProcurementError(err) return CreateProcurementRequestResult{}, wrapProcurementError(err)
} }
if !referenceUsed {
service.cleanupAutomaticReference(auto)
}
return CreateProcurementRequestResult{ return CreateProcurementRequestResult{
Request: stored, Request: stored,
Replayed: !created, Replayed: !created,
@@ -207,19 +247,95 @@ func (service *ProcurementService) BindReference(
fields, fields,
) )
} }
request, err := service.repository.BindProcurementReference( request, replacedStorageKey, err :=
ctx, service.repository.BindProcurementReference(
command.CreatorSubject, ctx,
command.RequestID, command.CreatorSubject,
command.ImageAssetID, command.RequestID,
service.clock.Now().UTC(), command.ImageAssetID,
) service.clock.Now().UTC(),
)
if err != nil { if err != nil {
return domain.ProcurementRequest{}, wrapProcurementError(err) return domain.ProcurementRequest{}, wrapProcurementError(err)
} }
if replacedStorageKey != nil {
service.deleteStoredReference(*replacedStorageKey)
}
return request, nil return request, nil
} }
func (service *ProcurementService) copyFreightImageReference(
ctx context.Context,
request domain.ProcurementRequest,
now time.Time,
) (*AutoProcurementReference, error) {
image, err := service.repository.GetReadyFreightItemImage(
ctx,
request.CreatorSubject,
request.FreightOrderItemID,
)
if errors.Is(err, ErrRepositoryNotFound) {
return nil, nil
}
if err != nil {
return nil, wrapRepositoryError(err)
}
assetID, err := service.ids.NewID()
if err != nil {
return nil, procurementInternal(err)
}
content, err := service.store.Open(ctx, image.StorageKey)
if err != nil {
return nil, mapImageStoreError(err)
}
normalized, putErr := service.store.Put(
ctx,
assetID,
image.MediaType,
content,
)
closeErr := content.Close()
if putErr != nil {
return nil, mapImageStoreError(putErr)
}
if closeErr != nil {
service.deleteStoredReference(normalized.StorageKey)
return nil, procurementInternal(closeErr)
}
return &AutoProcurementReference{
Asset: domain.Asset{
ID: assetID,
CreatorSubject: request.CreatorSubject,
Purpose: domain.AssetPurposeTaskReference,
MediaType: normalized.MediaType,
SizeBytes: normalized.SizeBytes,
SHA256: normalized.SHA256,
StorageKey: normalized.StorageKey,
CreatedAt: now,
},
ProductThumbRef: image.ProductThumbRef,
SourceImageSHA256: image.SHA256,
SourceImageStorageKey: image.StorageKey,
}, nil
}
func (service *ProcurementService) cleanupAutomaticReference(
auto *AutoProcurementReference,
) {
if auto != nil {
service.deleteStoredReference(auto.Asset.StorageKey)
}
}
func (service *ProcurementService) deleteStoredReference(storageKey string) {
if service.store == nil || storageKey == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = service.store.Delete(ctx, storageKey)
}
func (service *ProcurementService) CreateTask( func (service *ProcurementService) CreateTask(
ctx context.Context, ctx context.Context,
command CreateProcurementTaskCommand, command CreateProcurementTaskCommand,
@@ -0,0 +1,61 @@
-- +goose Up
CREATE TABLE procurement_reference_sources (
procurement_request_id TEXT PRIMARY KEY NOT NULL
REFERENCES procurement_requests(id)
ON UPDATE RESTRICT ON DELETE RESTRICT,
origin TEXT NOT NULL
CHECK (origin IN ('ERP_FREIGHT_IMAGE', 'MANUAL_UPLOAD')),
product_thumb_ref TEXT
CHECK (
product_thumb_ref IS NULL
OR (
product_thumb_ref GLOB '[1-9]*'
AND product_thumb_ref NOT GLOB '*[^0-9]*'
AND length(product_thumb_ref) <= 20
)
),
source_image_sha256 TEXT
CHECK (
source_image_sha256 IS NULL
OR (
length(source_image_sha256) = 64
AND source_image_sha256 NOT GLOB '*[^0-9a-f]*'
)
),
bound_at TEXT NOT NULL,
CHECK (
(origin = 'ERP_FREIGHT_IMAGE'
AND product_thumb_ref IS NOT NULL
AND source_image_sha256 IS NOT NULL)
OR (origin = 'MANUAL_UPLOAD'
AND product_thumb_ref IS NULL
AND source_image_sha256 IS NULL)
)
);
INSERT INTO procurement_reference_sources (
procurement_request_id, origin, product_thumb_ref,
source_image_sha256, bound_at
)
SELECT id, 'MANUAL_UPLOAD', NULL, NULL, updated_at
FROM procurement_requests
WHERE reference_asset_id IS NOT NULL;
CREATE INDEX procurement_reference_sources_origin_idx
ON procurement_reference_sources (origin, bound_at, procurement_request_id);
-- +goose Down
CREATE TEMP TABLE procurement_reference_sources_v17_down_guard (
allowed INTEGER NOT NULL CHECK (allowed = 1)
);
INSERT INTO procurement_reference_sources_v17_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM procurement_reference_sources)
THEN 0
ELSE 1
END;
DROP TABLE procurement_reference_sources_v17_down_guard;
DROP INDEX procurement_reference_sources_origin_idx;
DROP TABLE procurement_reference_sources;
+7 -3
View File
@@ -5,7 +5,7 @@
## 当前快照 ## 当前快照
- 日期:2026-07-29 - 日期:2026-07-29
- 阶段:T-244 已完成 ERP 图片缺失媒体类型兼容 - 阶段:T-245 已完成 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 源码
@@ -41,13 +41,17 @@
货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin 货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin
可查看查询范围、计数、稳定错误码和最近成功水位。 可查看查询范围、计数、稳定错误码和最近成功水位。
- 采购需求:v13 按货运商品 revision 保存可复核快照;ADMIN 显式确认采购、上传参考 - 采购需求:v13 按货运商品 revision 保存可复核快照;ADMIN 显式确认采购、上传参考
图后生成不可变 PENDING 任务。来源变化不会改写任务,Roubao 可沿用现有 claim 流程。 图后生成不可变 PENDING 任务。T-245 在当前商品有 `READY` 本地 ERP 缓存图时,将其
重新校验并复制为独立 `TASK_REFERENCE`,需求直接进入 `READY`;v17 记录
`ERP_FREIGHT_IMAGE`/`MANUAL_UPLOAD` 来源。无图仍进入 `NEEDS_IMAGE`,既有需求可在
图片恢复后显式升级;任务生成前可人工替换并清理旧自动副本。来源变化不会改写任务,
Roubao 继续使用原 `image_asset_id` 和 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-226 至 T-244 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` - 后端测试:T-226 至 T-245 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、 和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准 详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
验证也通过,均未访问真实 ERP; 验证也通过,均未访问真实 ERP;
+20 -11
View File
@@ -5,7 +5,7 @@ phase: 2
deps: deps:
- T-243 - T-243
- T-244 - T-244
status: TODO status: DONE
created: 2026-07-29 created: 2026-07-29
context_ref: 9195019 context_ref: 9195019
work_branch: null work_branch: null
@@ -69,17 +69,17 @@ write_paths:
## 验收要点 ## 验收要点
- [ ] 当前商品有 `READY` ERP 图时,创建需求返回 `READY`、独立 asset 和 - [x] 当前商品有 `READY` ERP 图时,创建需求返回 `READY`、独立 asset 和
`ERP_FREIGHT_IMAGE` 来源。 `ERP_FREIGHT_IMAGE` 来源。
- [ ] 自动 asset 使用不同 storage key;货运缓存图替换不会破坏采购参考图。 - [x] 自动 asset 使用不同 storage key;货运缓存图替换不会破坏采购参考图。
- [ ] 无可用 ERP 图时返回 `NEEDS_IMAGE`,人工上传路径不回归。 - [x] 无可用 ERP 图时返回 `NEEDS_IMAGE`,人工上传路径不回归。
- [ ] 资料阻塞时不复制文件、不创建 asset,状态保持 `BLOCKED`。 - [x] 资料阻塞时不复制文件、不创建 asset,状态保持 `BLOCKED`。
- [ ] 重复创建不产生重复需求/asset;既有 `NEEDS_IMAGE` 可在图片恢复后升级。 - [x] 重复创建不产生重复需求/asset;既有 `NEEDS_IMAGE` 可在图片恢复后升级。
- [ ] 商品或图片并发变化时无采购 asset、来源记录或孤立复制文件。 - [x] 商品或图片并发变化时无采购 asset、来源记录或孤立复制文件。
- [ ] `READY` 需求可人工替换;旧 ERP 自动 asset/文件被清理,任务创建后禁止替换。 - [x] `READY` 需求可人工替换;旧 ERP 自动 asset/文件被清理,任务创建后禁止替换。
- [ ] API/SSR 展示参考图来源,生成的采购任务和 Roubao 图片契约不变化。 - [x] API/SSR 展示参考图来源,生成的采购任务和 Roubao 图片契约不变化。
- [ ] 已自动绑定参考图的货运单继续受 T-243 删除保护。 - [x] 已自动绑定参考图的货运单继续受 T-243 删除保护。
- [ ] v17 上下迁移、标准 Go 测试、race、vet 和三个入口构建通过。 - [x] v17 上下迁移、标准 Go 测试、race、vet 和三个入口构建通过。
## 边界 ## 边界
@@ -96,3 +96,12 @@ write_paths:
- 2026-07-29:审计确认当前 `CreateRequest` 对非阻塞商品固定写入 `NEEDS_IMAGE`;ERP - 2026-07-29:审计确认当前 `CreateRequest` 对非阻塞商品固定写入 `NEEDS_IMAGE`;ERP
缓存图只有 `freight_item_images` 记录,而采购任务要求 `assets` 外键。确定采用独立 缓存图只有 `freight_item_images` 记录,而采购任务要求 `assets` 外键。确定采用独立
文件/asset 快照,不共享可变货运 storage key;Roubao 协议无需变化。 文件/asset 快照,不共享可变货运 storage key;Roubao 协议无需变化。
- 2026-07-29:v17 已增加参考图来源记录;生产组合启用本地 ERP 缓存图复制。SQLite
事务复核商品 revision/hash 和图片引用/hash/key 后,原子写 asset、需求及来源;重复
创建可升级既有 `NEEDS_IMAGE`,未使用或失败的复制文件由用例补偿清理。
- 2026-07-29:人工绑定已支持任务生成前替换自动参考图,提交后清理旧自动 asset/文件;
API 返回 `reference_origin`,货运详情显示来源并提供“更换参考图”。任务仍使用既有
`image_asset_id`,未改变 Roubao 协议。
- 2026-07-29:`go test ./...`、`go test -race ./...`、`go vet ./...` 通过;
`go build ./cmd/api`、`go build ./cmd/authctl`、`go build ./cmd/migrate` 通过。
测试仅使用运行时生成图片和脱敏 ID,未访问真实 ERP。