feat(t245): use ERP images as procurement references
This commit is contained in:
@@ -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(¤tRevision, ¤tSHA, &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}}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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。
|
||||||
|
|||||||
Reference in New Issue
Block a user