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 {
return nil, err
}
procurement, err := usecase.NewProcurementService(store, clock, ids)
procurement, err := usecase.NewProcurementService(
store,
clock,
ids,
usecase.WithProcurementReferenceStore(files),
)
if err != nil {
return nil, err
}
@@ -3,6 +3,7 @@ package domain
import "time"
type ProcurementRequestStatus string
type ProcurementReferenceOrigin string
const (
ProcurementBlocked ProcurementRequestStatus = "BLOCKED"
@@ -20,6 +21,11 @@ const (
ProcurementBlockSourceChanged = "SOURCE_CHANGED"
)
const (
ProcurementReferenceERP ProcurementReferenceOrigin = "ERP_FREIGHT_IMAGE"
ProcurementReferenceManual ProcurementReferenceOrigin = "MANUAL_UPLOAD"
)
type ProcurementSourceItem struct {
Item FreightOrderItem
IsCanceled *bool
@@ -42,12 +48,23 @@ type ProcurementRequest struct {
Status ProcurementRequestStatus
BlockingCode *string
ReferenceAssetID *string
ReferenceOrigin *ProcurementReferenceOrigin
ReferenceProductThumbRef *string
ReferenceSourceImageSHA256 *string
ReferenceBoundAt *time.Time
PurchaseTaskID *string
SourceChanged bool
CreatedAt time.Time
UpdatedAt time.Time
}
type ProcurementReferenceSource struct {
Origin ProcurementReferenceOrigin
ProductThumbRef *string
SourceImageSHA256 *string
BoundAt time.Time
}
type PurchaseTaskSource struct {
TaskID string
ProcurementRequestID string
@@ -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 != 16 {
t.Fatalf("initial Up() applied = %d, want 16", applied)
} else if applied != 17 {
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 {
t.Fatalf("initial Down(v16) error = %v", err)
@@ -77,9 +80,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v16) over historical data error = %v", err)
} else if applied != 12 {
t.Fatalf("Up(v5-v16) applied = %d, want 12", applied)
t.Fatalf("Up(v5-v17) over historical data error = %v", err)
} else if applied != 13 {
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)
@@ -149,9 +157,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v16) error = %v", err)
} else if applied != 13 {
t.Fatalf("final Up(v4-v16) applied = %d, want 13", applied)
t.Fatalf("final Up(v4-v17) error = %v", err)
} else if applied != 14 {
t.Fatalf("final Up(v4-v17) applied = %d, want 14", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -387,6 +395,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
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 {
t.Fatalf("Down(v16) error = %v", err)
}
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 16 {
t.Fatalf("Up() applied = %d, want 16", applied)
if applied != 17 {
t.Fatalf("Up() applied = %d, want 17", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -47,6 +47,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
14: true,
15: true,
16: true,
17: true,
})
applied, err = runner.Up(context.Background())
@@ -76,7 +77,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
13: true,
14: true,
15: true,
16: false,
16: true,
17: false,
})
applied, err = runner.Up(context.Background())
@@ -103,6 +105,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
14: true,
15: true,
16: true,
17: true,
})
}
@@ -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(v17) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v16) error = %v", err)
}
@@ -439,8 +442,8 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v16) error = %v", err)
} else if applied != 14 {
t.Fatalf("Up(v3-v16) applied = %d, want 14", applied)
} else if applied != 15 {
t.Fatalf("Up(v3-v17) applied = %d, want 15", applied)
}
}
@@ -189,6 +189,9 @@ func TestFreightItemImagesAreCurrentRetryableAndRollbackGuarded(
if err != nil {
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 {
t.Fatal("image migration down succeeded with retained image metadata")
}
@@ -131,6 +131,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("reference source migration down: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("image migration down: %v", err)
}
@@ -271,6 +274,9 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
}
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 {
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(
ctx context.Context,
candidate domain.ProcurementRequest,
) (domain.ProcurementRequest, bool, error) {
auto *usecase.AutoProcurementReference,
) (domain.ProcurementRequest, bool, bool, error) {
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return domain.ProcurementRequest{}, false, repositoryFailure(err)
return domain.ProcurementRequest{}, false, false,
repositoryFailure(err)
}
defer tx.Rollback()
existing, err := getProcurementRequestBySource(
@@ -80,36 +82,60 @@ func (store *Store) CreateProcurementRequest(
candidate.SourceRevision,
)
if err == nil {
if err := tx.Commit(); err != nil {
return domain.ProcurementRequest{}, false, repositoryFailure(err)
if auto != nil &&
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) {
return domain.ProcurementRequest{}, false, err
return domain.ProcurementRequest{}, false, 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(&currentRevision, &currentSHA, &present); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ProcurementRequest{}, false, usecase.ErrRepositoryNotFound
if err := validateProcurementSource(ctx, tx, candidate); err != nil {
return domain.ProcurementRequest{}, false, false, err
}
referenceUsed := false
if auto != nil {
if candidate.Status != domain.ProcurementNeedsImage {
return domain.ProcurementRequest{}, false, false,
repositoryFailure(usecase.ErrRepositoryInvariant)
}
return domain.ProcurementRequest{}, false, repositoryFailure(err)
}
if !present || currentRevision != candidate.SourceRevision ||
currentSHA != candidate.SourceSHA256 {
return domain.ProcurementRequest{}, false,
usecase.ErrProcurementSourceChanged
if err := validateAutomaticProcurementReference(
ctx,
tx,
candidate,
*auto,
); 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(
ctx,
@@ -120,7 +146,7 @@ func (store *Store) CreateProcurementRequest(
procurement_confirmed_by_user_id, procurement_confirmed_at,
status, blocking_code, reference_asset_id, purchase_task_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
candidate.ID,
candidate.CreatorSubject,
candidate.FreightOrderItemID,
@@ -136,16 +162,43 @@ func (store *Store) CreateProcurementRequest(
formatTimestamp(candidate.ProcurementConfirmedAt),
candidate.Status,
nullableString(candidate.BlockingCode),
nullableString(candidate.ReferenceAssetID),
formatTimestamp(candidate.CreatedAt),
formatTimestamp(candidate.UpdatedAt),
)
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 {
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(
@@ -190,10 +243,10 @@ func (store *Store) BindProcurementReference(
ctx context.Context,
creatorSubject, requestID, assetID string,
now time.Time,
) (domain.ProcurementRequest, error) {
) (domain.ProcurementRequest, *string, error) {
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err)
return domain.ProcurementRequest{}, nil, repositoryFailure(err)
}
defer tx.Rollback()
request, err := getProcurementRequest(
@@ -203,30 +256,33 @@ func (store *Store) BindProcurementReference(
requestID,
)
if err != nil {
return domain.ProcurementRequest{}, err
return domain.ProcurementRequest{}, nil, err
}
if request.SourceChanged {
if request.PurchaseTaskID == 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 {
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 &&
*request.ReferenceAssetID == assetID &&
(request.Status == domain.ProcurementReady ||
request.Status == domain.ProcurementTaskCreated) {
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 {
return domain.ProcurementRequest{}, usecase.ErrProcurementStateConflict
if request.Status != domain.ProcurementNeedsImage &&
request.Status != domain.ProcurementReady {
return domain.ProcurementRequest{}, nil,
usecase.ErrProcurementStateConflict
}
var available int
if err := tx.QueryRowContext(
@@ -250,30 +306,68 @@ func (store *Store) BindProcurementReference(
creatorSubject,
requestID,
).Scan(&available); err != nil {
return domain.ProcurementRequest{}, repositoryFailure(err)
return domain.ProcurementRequest{}, nil, repositoryFailure(err)
}
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(
ctx,
`UPDATE procurement_requests
SET reference_asset_id = ?, status = 'READY', updated_at = ?
WHERE id = ? AND status = 'NEEDS_IMAGE'`,
SET reference_asset_id = ?, status = 'READY', blocking_code = NULL,
updated_at = ?
WHERE id = ? AND status IN ('NEEDS_IMAGE', 'READY')`,
assetID,
formatTimestamp(now),
requestID,
); 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)
if err != nil {
return domain.ProcurementRequest{}, err
return domain.ProcurementRequest{}, nil, err
}
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(
@@ -488,6 +582,8 @@ const procurementRequestSelect = `SELECT
request.procurement_confirmed_by_user_id,
request.procurement_confirmed_at, request.status,
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,
CASE
WHEN item.revision != request.source_revision
@@ -502,6 +598,8 @@ const procurementRequestSelect = `SELECT
ON item.id = request.freight_order_item_id
JOIN freight_orders AS freight
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(
@@ -557,7 +655,9 @@ func scanProcurementRequest(
var quantity sql.NullInt64
var purchaseStatus sql.NullString
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 sourceChanged bool
err := scanner.Scan(
@@ -577,6 +677,10 @@ func scanProcurementRequest(
&request.Status,
&blockingCode,
&assetID,
&referenceOrigin,
&referenceThumb,
&referenceSHA,
&referenceBoundAt,
&taskID,
&createdAt,
&updatedAt,
@@ -595,6 +699,19 @@ func scanProcurementRequest(
}
request.BlockingCode = optionalString(blockingCode)
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.SourceChanged = sourceChanged
request.ProcurementConfirmedAt, err = parseTimestamp(confirmedAt)
@@ -609,6 +726,204 @@ func scanProcurementRequest(
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(
ctx context.Context,
tx *sql.Tx,
@@ -238,6 +238,17 @@ func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable(
if err != nil {
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 {
t.Fatalf("image migration down: %v", err)
}
@@ -766,7 +766,11 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
"",
)
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(
"create request status/body = %d / %s",
createRequest.Code,
@@ -806,7 +810,11 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
"",
)
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)
}
deleteWithReference := performAdminRequest(
@@ -920,7 +928,7 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
}
func TestAdminFreightDeleteRemovesNeedsImageDraft(t *testing.T) {
fixture := newAdminIntegrationFixture(t)
fixture := newAdminIntegrationFixtureWithReferences(t, false)
createSync := performAdminRequest(
t,
fixture.router,
@@ -1123,6 +1131,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("reference source migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("image migration down: %v", err)
}
@@ -1406,6 +1417,13 @@ type adminIntegrationFixture struct {
}
func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
return newAdminIntegrationFixtureWithReferences(t, true)
}
func newAdminIntegrationFixtureWithReferences(
t *testing.T,
autoReferences bool,
) *adminIntegrationFixture {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
@@ -1482,10 +1500,18 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
if err != nil {
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(
repositories,
clock,
ids,
procurementOptions...,
)
if err != nil {
t.Fatalf("usecase.NewProcurementService() error = %v", err)
@@ -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("reference source migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
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 {
t.Fatalf("restore device command migration: %v", err)
} else if applied != 8 {
t.Fatalf("restored migrations = %d, want 8", applied)
} else if applied != 9 {
t.Fatalf("restored migrations = %d, want 9", applied)
}
completePayload := fmt.Sprintf(
@@ -1432,6 +1435,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if err != nil {
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 {
t.Fatalf("image migration down: %v", err)
}
@@ -160,6 +160,7 @@ func procurementRequestResponse(
"status": request.Status,
"blocking_code": request.BlockingCode,
"reference_asset_id": request.ReferenceAssetID,
"reference_origin": request.ReferenceOrigin,
"purchase_task_id": request.PurchaseTaskID,
"source_changed": request.SourceChanged,
"created_at": formatTime(request.CreatedAt),
@@ -1239,10 +1239,12 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
ImageURL: "/api/v1/freight-items/" + itemID + "/image",
},
Request: &ProcurementRequest{
ID: itemID,
FreightOrderItemID: itemID,
Status: "READY",
StatusLabel: "可以生成任务",
ID: itemID,
FreightOrderItemID: itemID,
Status: "READY",
StatusLabel: "可以生成任务",
ReferenceOrigin: "ERP_FREIGHT_IMAGE",
ReferenceOriginLabel: "参考图来自 ERP 商品图",
},
}},
},
@@ -1263,6 +1265,8 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
)
if detail.Code != http.StatusOK ||
!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(), "TWD 129.50") ||
!strings.Contains(detail.Body.String(), `width="84" height="84"`) ||
@@ -1273,6 +1277,13 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
}
cookie := csrfCookie(t, detail)
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{
"csrf_token": {cookie.Value},
"order_id": {testTaskID},
@@ -103,6 +103,7 @@
<button class="button" type="submit">上传参考图</button>
</form>
{{else if eq .Request.Status "READY"}}
{{if .Request.ReferenceOriginLabel}}<span class="secondary">{{.Request.ReferenceOriginLabel}}</span>{{end}}
<form method="post"
action="/freight/procurement-requests/{{pathPart .Request.ID}}/purchase-task">
<input type="hidden" name="csrf_token" value="{{$.Page.CSRFToken}}">
@@ -110,6 +111,17 @@
<input type="hidden" name="task_key" value="{{.Request.TaskKey}}">
<button class="button primary" type="submit">生成采购任务</button>
</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}}
<a class="detail-link" href="/tasks/{{pathPart .Request.PurchaseTaskID}}">查看采购任务</a>
{{end}}
+14 -12
View File
@@ -192,18 +192,20 @@ type FreightItemReview struct {
}
type ProcurementRequest struct {
ID string
FreightOrderItemID string
SourceRevision int
Status string
StatusLabel string
BlockingCode string
BlockingLabel string
ReferenceAssetID string
PurchaseTaskID string
SourceChanged bool
UploadKey string
TaskKey string
ID string
FreightOrderItemID string
SourceRevision int
Status string
StatusLabel string
BlockingCode string
BlockingLabel string
ReferenceAssetID string
ReferenceOrigin string
ReferenceOriginLabel string
PurchaseTaskID string
SourceChanged bool
UploadKey string
TaskKey string
}
type ListTasksInput struct {
@@ -292,9 +292,11 @@ func procurementRequestFrom(
BlockingLabel: procurementBlockingLabel(
stringValue(request.BlockingCode),
),
ReferenceAssetID: stringValue(request.ReferenceAssetID),
PurchaseTaskID: stringValue(request.PurchaseTaskID),
SourceChanged: request.SourceChanged,
ReferenceAssetID: stringValue(request.ReferenceAssetID),
ReferenceOrigin: procurementReferenceOrigin(request),
ReferenceOriginLabel: procurementReferenceOriginLabel(request),
PurchaseTaskID: stringValue(request.PurchaseTaskID),
SourceChanged: request.SourceChanged,
}
if request.SourceChanged {
result.StatusLabel = "来源已变化"
@@ -302,6 +304,28 @@ func procurementRequestFrom(
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(
status domain.ProcurementRequestStatus,
) string {
@@ -7,16 +7,29 @@ import (
"cmroubao/backend-api/internal/domain"
)
type AutoProcurementReference struct {
Asset domain.Asset
ProductThumbRef string
SourceImageSHA256 string
SourceImageStorageKey string
}
type ProcurementRepository interface {
GetProcurementSourceItem(
context.Context,
string,
string,
) (domain.ProcurementSourceItem, error)
GetReadyFreightItemImage(
context.Context,
string,
string,
) (domain.FreightItemImage, error)
CreateProcurementRequest(
context.Context,
domain.ProcurementRequest,
) (domain.ProcurementRequest, bool, error)
*AutoProcurementReference,
) (domain.ProcurementRequest, bool, bool, error)
ListProcurementRequestsForOrder(
context.Context,
string,
@@ -33,7 +46,7 @@ type ProcurementRepository interface {
string,
string,
time.Time,
) (domain.ProcurementRequest, error)
) (domain.ProcurementRequest, *string, error)
CreateProcurementTask(
context.Context,
domain.ProcurementRequest,
@@ -5,6 +5,7 @@ import (
"errors"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
@@ -21,10 +22,25 @@ var (
type ProcurementService struct {
repository ProcurementRepository
store ReferenceImageStore
clock Clock
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 {
CreatorSubject string
ActorUserID string
@@ -60,15 +76,25 @@ func NewProcurementService(
repository ProcurementRepository,
clock Clock,
ids IDGenerator,
options ...ProcurementServiceOption,
) (*ProcurementService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("procurement service dependencies are required")
}
return &ProcurementService{
service := &ProcurementService{
repository: repository,
clock: clock,
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(
@@ -136,13 +162,27 @@ func (service *ProcurementService) CreateRequest(
request.Status = domain.ProcurementBlocked
request.BlockingCode = &code
}
stored, created, err := service.repository.CreateProcurementRequest(
ctx,
request,
)
var auto *AutoProcurementReference
if request.Status == domain.ProcurementNeedsImage &&
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 {
service.cleanupAutomaticReference(auto)
return CreateProcurementRequestResult{}, wrapProcurementError(err)
}
if !referenceUsed {
service.cleanupAutomaticReference(auto)
}
return CreateProcurementRequestResult{
Request: stored,
Replayed: !created,
@@ -207,19 +247,95 @@ func (service *ProcurementService) BindReference(
fields,
)
}
request, err := service.repository.BindProcurementReference(
ctx,
command.CreatorSubject,
command.RequestID,
command.ImageAssetID,
service.clock.Now().UTC(),
)
request, replacedStorageKey, err :=
service.repository.BindProcurementReference(
ctx,
command.CreatorSubject,
command.RequestID,
command.ImageAssetID,
service.clock.Now().UTC(),
)
if err != nil {
return domain.ProcurementRequest{}, wrapProcurementError(err)
}
if replacedStorageKey != nil {
service.deleteStoredReference(*replacedStorageKey)
}
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(
ctx context.Context,
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
- 阶段:T-244 已完成 ERP 图片缺失媒体类型兼容
- 阶段:T-245 已完成 ERP 商品图自动作为采购参考图
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
@@ -41,13 +41,17 @@
货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。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、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
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 会话预检、完整单号、日期分页去重、
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
验证也通过,均未访问真实 ERP;
+20 -11
View File
@@ -5,7 +5,7 @@ phase: 2
deps:
- T-243
- T-244
status: TODO
status: DONE
created: 2026-07-29
context_ref: 9195019
work_branch: null
@@ -69,17 +69,17 @@ write_paths:
## 验收要点
- [ ] 当前商品有 `READY` ERP 图时,创建需求返回 `READY`、独立 asset 和
- [x] 当前商品有 `READY` ERP 图时,创建需求返回 `READY`、独立 asset 和
`ERP_FREIGHT_IMAGE` 来源。
- [ ] 自动 asset 使用不同 storage key;货运缓存图替换不会破坏采购参考图。
- [ ] 无可用 ERP 图时返回 `NEEDS_IMAGE`,人工上传路径不回归。
- [ ] 资料阻塞时不复制文件、不创建 asset,状态保持 `BLOCKED`。
- [ ] 重复创建不产生重复需求/asset;既有 `NEEDS_IMAGE` 可在图片恢复后升级。
- [ ] 商品或图片并发变化时无采购 asset、来源记录或孤立复制文件。
- [ ] `READY` 需求可人工替换;旧 ERP 自动 asset/文件被清理,任务创建后禁止替换。
- [ ] API/SSR 展示参考图来源,生成的采购任务和 Roubao 图片契约不变化。
- [ ] 已自动绑定参考图的货运单继续受 T-243 删除保护。
- [ ] v17 上下迁移、标准 Go 测试、race、vet 和三个入口构建通过。
- [x] 自动 asset 使用不同 storage key;货运缓存图替换不会破坏采购参考图。
- [x] 无可用 ERP 图时返回 `NEEDS_IMAGE`,人工上传路径不回归。
- [x] 资料阻塞时不复制文件、不创建 asset,状态保持 `BLOCKED`。
- [x] 重复创建不产生重复需求/asset;既有 `NEEDS_IMAGE` 可在图片恢复后升级。
- [x] 商品或图片并发变化时无采购 asset、来源记录或孤立复制文件。
- [x] `READY` 需求可人工替换;旧 ERP 自动 asset/文件被清理,任务创建后禁止替换。
- [x] API/SSR 展示参考图来源,生成的采购任务和 Roubao 图片契约不变化。
- [x] 已自动绑定参考图的货运单继续受 T-243 删除保护。
- [x] v17 上下迁移、标准 Go 测试、race、vet 和三个入口构建通过。
## 边界
@@ -96,3 +96,12 @@ write_paths:
- 2026-07-29:审计确认当前 `CreateRequest` 对非阻塞商品固定写入 `NEEDS_IMAGE`;ERP
缓存图只有 `freight_item_images` 记录,而采购任务要求 `assets` 外键。确定采用独立
文件/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。