From 3bd9726ddc0c2e0e57a0aca432406a1bb280569b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 14:54:58 +0800 Subject: [PATCH] feat(t239): persist freight item metadata --- backend-api/internal/domain/freight.go | 71 ++++++------ .../migration/claims_migration_test.go | 27 +++-- .../platform/migration/runner_test.go | 9 +- .../platform/shunyunbao/normalizer.go | 108 ++++++++++++++++-- .../platform/shunyunbao/protocol_test.go | 78 ++++++++++++- .../platform/shunyunbao/source_test.go | 8 +- .../shunyunbao/testdata/freight-result.json | 1 + .../repository/sqlite/auth_repository_test.go | 9 +- .../repository/sqlite/freight_repository.go | 18 ++- .../sqlite/freight_repository_test.go | 50 +++++--- .../sqlite/procurement_repository.go | 3 +- .../sqlite/procurement_repository_test.go | 3 + .../transport/httpapi/admin_handlers_test.go | 37 +++--- .../transport/httpapi/device_handlers_test.go | 10 +- .../transport/httpapi/freight_handlers.go | 24 ++-- backend-api/internal/transport/webui/types.go | 20 ++-- .../transport/webui/usecase_adapter.go | 18 +-- .../internal/usecase/freight_service.go | 41 ++++--- .../internal/usecase/freight_service_test.go | 30 +++-- .../00015_freight_item_metadata.sql | 29 +++++ docs/api.md | 9 +- docs/current-state.md | 11 +- docs/tasks/T-239.md | 20 ++-- 23 files changed, 476 insertions(+), 158 deletions(-) create mode 100644 backend-api/migrations/00015_freight_item_metadata.sql diff --git a/backend-api/internal/domain/freight.go b/backend-api/internal/domain/freight.go index 374984b..94d4eb7 100644 --- a/backend-api/internal/domain/freight.go +++ b/backend-api/internal/domain/freight.go @@ -6,6 +6,7 @@ const ( FreightSourceShunyunbao = "SHUNYUNBAO" FreightSyncOrderNumber = "ORDER_NUMBER" FreightSyncCreatedRange = "CREATED_RANGE" + FreightCurrencyTWD = "TWD" ) type FreightSyncStatus string @@ -58,22 +59,24 @@ type FreightOrder struct { } type FreightOrderItem struct { - ID string - FreightOrderID string - ExternalItemID string - Title string - ProductSpec string - SKU string - Quantity *int - ProductThumbRef *string - PurchaseStatus *string - CanonicalSHA256 string - Revision int - IsPresent bool - FirstSyncRunID string - LastSyncRunID string - CreatedAt time.Time - UpdatedAt time.Time + ID string + FreightOrderID string + ExternalItemID string + Title string + ProductSpec string + SKU string + Quantity *int + ProductThumbRef *string + OriginalUnitPriceMinor *int64 + OriginalCurrency string + PurchaseStatus *string + CanonicalSHA256 string + Revision int + IsPresent bool + FirstSyncRunID string + LastSyncRunID string + CreatedAt time.Time + UpdatedAt time.Time } type FreightOrderDetail struct { @@ -114,13 +117,15 @@ type FreightSourceOrder struct { } type FreightSourceItem struct { - ExternalItemID string `json:"external_item_id"` - Title string `json:"title"` - ProductSpec string `json:"product_spec"` - SKU string `json:"sku"` - Quantity *int `json:"quantity"` - ProductThumbRef *string `json:"product_thumb_ref"` - PurchaseStatus *string `json:"purchase_status"` + ExternalItemID string `json:"external_item_id"` + Title string `json:"title"` + ProductSpec string `json:"product_spec"` + SKU string `json:"sku"` + Quantity *int `json:"quantity"` + ProductThumbRef *string `json:"product_thumb_ref"` + OriginalUnitPriceMinor *int64 `json:"original_unit_price_minor"` + OriginalCurrency string `json:"original_currency"` + PurchaseStatus *string `json:"purchase_status"` } type FreightImportBatch struct { @@ -142,13 +147,15 @@ type FreightImportOrder struct { } type FreightImportItem struct { - ID string - ExternalItemID string - Title string - ProductSpec string - SKU string - Quantity *int - ProductThumbRef *string - PurchaseStatus *string - CanonicalSHA256 string + ID string + ExternalItemID string + Title string + ProductSpec string + SKU string + Quantity *int + ProductThumbRef *string + OriginalUnitPriceMinor *int64 + OriginalCurrency string + PurchaseStatus *string + CanonicalSHA256 string } diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index 5cb8703..b855fad 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) - } else if applied != 14 { - t.Fatalf("initial Up() applied = %d, want 14", applied) + } else if applied != 15 { + t.Fatalf("initial Up() applied = %d, want 15", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v15) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v14) error = %v", err) @@ -71,9 +74,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("Up(v5-v14) over historical data error = %v", err) - } else if applied != 10 { - t.Fatalf("Up(v5-v14) applied = %d, want 10", applied) + t.Fatalf("Up(v5-v15) over historical data error = %v", err) + } else if applied != 11 { + t.Fatalf("Up(v5-v15) applied = %d, want 11", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v15) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) @@ -133,9 +141,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v14) error = %v", err) - } else if applied != 11 { - t.Fatalf("final Up(v4-v14) applied = %d, want 11", applied) + t.Fatalf("final Up(v4-v15) error = %v", err) + } else if applied != 12 { + t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied) } assertClaimsHistory(t, db, true) } @@ -371,6 +379,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { t.Fatalf("insert v4 audit event: %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v15) error = %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v14) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index 91f4d90..9371778 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 14 { - t.Fatalf("Up() applied = %d, want 14", applied) + if applied != 15 { + t.Fatalf("Up() applied = %d, want 15", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, @@ -45,6 +45,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 12: true, 13: true, 14: true, + 15: true, }) applied, err = runner.Up(context.Background()) @@ -72,7 +73,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 11: true, 12: true, 13: true, - 14: false, + 14: true, + 15: false, }) applied, err = runner.Up(context.Background()) @@ -97,6 +99,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 12: true, 13: true, 14: true, + 15: true, }) } diff --git a/backend-api/internal/platform/shunyunbao/normalizer.go b/backend-api/internal/platform/shunyunbao/normalizer.go index cce0493..f4f0476 100644 --- a/backend-api/internal/platform/shunyunbao/normalizer.go +++ b/backend-api/internal/platform/shunyunbao/normalizer.go @@ -156,21 +156,109 @@ func normalizeItem(value map[string]any) (domain.FreightSourceItem, error) { if title == "" { title = text(value["detailProductName"]) } - sku := text(value["sku"]) - if sku == "" { - sku = text(value["variationSku"]) + productSpec := text(value["productSpec"]) + productThumbRef, err := optionalExternalID(value["productThumb"]) + if err != nil { + return domain.FreightSourceItem{}, err + } + originalUnitPriceMinor, err := priceMinor(value["productPrice"]) + if err != nil { + return domain.FreightSourceItem{}, err } return domain.FreightSourceItem{ - ExternalItemID: externalItemID, - Title: title, - ProductSpec: text(value["productSpec"]), - SKU: sku, - Quantity: positiveInt(value["productQty"]), - ProductThumbRef: optionalScalar(value["productThumb"]), - PurchaseStatus: optionalScalar(value["purchaseStatus"]), + ExternalItemID: externalItemID, + Title: title, + ProductSpec: productSpec, + SKU: productSpec, + Quantity: positiveInt(value["productQty"]), + ProductThumbRef: productThumbRef, + OriginalUnitPriceMinor: originalUnitPriceMinor, + OriginalCurrency: domain.FreightCurrencyTWD, + PurchaseStatus: optionalScalar(value["purchaseStatus"]), }, nil } +func optionalExternalID(value any) (*string, error) { + if value == nil { + return nil, nil + } + if typed, ok := value.(string); ok && strings.TrimSpace(typed) == "" { + return nil, nil + } + normalized, err := externalID(value) + if err != nil { + return nil, err + } + return &normalized, nil +} + +func priceMinor(value any) (*int64, error) { + if value == nil { + return nil, nil + } + var raw string + switch typed := value.(type) { + case string: + raw = strings.TrimSpace(typed) + case json.Number: + raw = string(typed) + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || typed < 0 { + return nil, errInvalidProtocolInput + } + raw = strconv.FormatFloat(typed, 'f', -1, 64) + case int: + raw = strconv.Itoa(typed) + case int64: + raw = strconv.FormatInt(typed, 10) + case uint64: + raw = strconv.FormatUint(typed, 10) + default: + return nil, errInvalidProtocolInput + } + if raw == "" { + return nil, nil + } + whole, fraction, hasFraction := strings.Cut(raw, ".") + if whole == "" || strings.Contains(fraction, ".") || + !decimalDigits(whole) || (hasFraction && !decimalDigits(fraction)) || + len(fraction) > 2 { + return nil, errInvalidProtocolInput + } + wholeValue, err := strconv.ParseUint(whole, 10, 64) + if err != nil || wholeValue > uint64(math.MaxInt64)/100 { + return nil, errInvalidProtocolInput + } + minor := wholeValue * 100 + if hasFraction { + fractionValue, err := strconv.ParseUint(fraction, 10, 8) + if err != nil { + return nil, errInvalidProtocolInput + } + if len(fraction) == 1 { + fractionValue *= 10 + } + minor += fractionValue + } + if minor > uint64(math.MaxInt64) { + return nil, errInvalidProtocolInput + } + result := int64(minor) + return &result, nil +} + +func decimalDigits(value string) bool { + if value == "" { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + func externalID(value any) (string, error) { var normalized uint64 switch typed := value.(type) { diff --git a/backend-api/internal/platform/shunyunbao/protocol_test.go b/backend-api/internal/platform/shunyunbao/protocol_test.go index d04ec75..19891cd 100644 --- a/backend-api/internal/platform/shunyunbao/protocol_test.go +++ b/backend-api/internal/platform/shunyunbao/protocol_test.go @@ -86,8 +86,11 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) { order := result.Orders[0] if order.ExternalStockID != "12" || order.SourceCode != "SANITIZED-CODE-12" || order.ShopName == nil || *order.ShopName != "Sanitized shop" || - order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "BLACK-L" || - order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 { + order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "Black,L" || + order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 || + order.Items[0].OriginalUnitPriceMinor == nil || + *order.Items[0].OriginalUnitPriceMinor != 12950 || + order.Items[0].OriginalCurrency != domain.FreightCurrencyTWD { t.Fatalf("allowlist result = %#v", order) } encoded, err := json.Marshal(result) @@ -105,6 +108,77 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) { } } +func TestNormalizeFreightPriceAndThumbContract(t *testing.T) { + tests := []struct { + name string + price any + thumb any + wantMinor *int64 + wantThumb *string + wantErr bool + }{ + {name: "integer", price: json.Number("12"), thumb: json.Number("190"), wantMinor: int64Pointer(1200), wantThumb: stringPointer("190")}, + {name: "one decimal", price: "12.3", thumb: "0190", wantMinor: int64Pointer(1230), wantThumb: stringPointer("190")}, + {name: "two decimals", price: json.Number("12.34"), wantMinor: int64Pointer(1234)}, + {name: "missing", price: "", thumb: "", wantMinor: nil, wantThumb: nil}, + {name: "negative", price: "-1", wantErr: true}, + {name: "exponent", price: json.Number("1e2"), wantErr: true}, + {name: "excess precision", price: "1.001", wantErr: true}, + {name: "overflow", price: "92233720368547758.08", wantErr: true}, + {name: "invalid thumb text", price: "1", thumb: "https://invalid.example/image", wantErr: true}, + {name: "invalid thumb zero", price: "1", thumb: 0, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := NormalizeFreightResult( + RawQuery{Mode: domain.FreightSyncOrderNumber}, + []RawRecord{{ + Stock: map[string]any{"id": 12, "code": "SANITIZED"}, + Detail: map[string]any{ + "id": 12, + "details": []any{map[string]any{ + "id": 88, + "productSpec": "Black,L", + "productPrice": test.price, + "productThumb": test.thumb, + }}, + }, + }}, + ) + if test.wantErr { + if !errors.Is(err, domain.ErrFreightSourceProtocol) { + t.Fatalf("NormalizeFreightResult() error = %v", err) + } + return + } + if err != nil { + t.Fatalf("NormalizeFreightResult() error = %v", err) + } + item := result.Orders[0].Items[0] + if !equalInt64Pointer(item.OriginalUnitPriceMinor, test.wantMinor) || + !equalStringPointer(item.ProductThumbRef, test.wantThumb) || + item.ProductSpec != "Black,L" || item.SKU != "Black,L" || + item.OriginalCurrency != domain.FreightCurrencyTWD { + t.Fatalf("normalized item = %#v", item) + } + }) + } +} + +func int64Pointer(value int64) *int64 { + return &value +} + +func equalInt64Pointer(left, right *int64) bool { + return left == nil && right == nil || + left != nil && right != nil && *left == *right +} + +func equalStringPointer(left, right *string) bool { + return left == nil && right == nil || + left != nil && right != nil && *left == *right +} + func TestNormalizeFreightNeverLeaksRawValuesInErrors(t *testing.T) { _, err := NormalizeFreightResult( RawQuery{Mode: domain.FreightSyncOrderNumber}, diff --git a/backend-api/internal/platform/shunyunbao/source_test.go b/backend-api/internal/platform/shunyunbao/source_test.go index fa7d05a..418d84a 100644 --- a/backend-api/internal/platform/shunyunbao/source_test.go +++ b/backend-api/internal/platform/shunyunbao/source_test.go @@ -51,7 +51,7 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) { t.Fatalf("detail query = %q", request.URL.RawQuery) } assertDetailPayload(t, request, []uint64{12}) - _, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"shopName":"测试店铺","created":"2026-07-28 08:00:00","details":[{"id":88,"productTitle":"商品一","productSpec":"黑色,L","sku":"BLACK-L","productQty":2,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","sku":"BLACK-XL","productQty":1}]}]}}`)) + _, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"shopName":"测试店铺","created":"2026-07-28 08:00:00","details":[{"id":88,"productTitle":"商品一","productSpec":"黑色,L","sku":"IGNORED-1","productQty":2,"productPrice":129.5,"productThumb":190,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","variationSku":"IGNORED-2","productQty":1,"productPrice":"88","productThumb":"191"}]}]}}`)) default: writer.WriteHeader(http.StatusNotFound) } @@ -67,8 +67,10 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) { if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber || len(result.Orders) != 1 || result.Orders[0].ExternalStockID != "12" || result.Orders[0].ShopName == nil || *result.Orders[0].ShopName != "测试店铺" || - len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "BLACK-L" || - result.Orders[0].Items[1].SKU != "BLACK-XL" { + len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "黑色,L" || + result.Orders[0].Items[1].SKU != "黑色,XL" || + result.Orders[0].Items[0].OriginalUnitPriceMinor == nil || + *result.Orders[0].Items[0].OriginalUnitPriceMinor != 12950 { t.Fatalf("QueryOrder() = %#v", result) } encoded, err := json.Marshal(result) diff --git a/backend-api/internal/platform/shunyunbao/testdata/freight-result.json b/backend-api/internal/platform/shunyunbao/testdata/freight-result.json index b984e1b..1276b17 100644 --- a/backend-api/internal/platform/shunyunbao/testdata/freight-result.json +++ b/backend-api/internal/platform/shunyunbao/testdata/freight-result.json @@ -32,6 +32,7 @@ "sku": "BLACK-L", "productQty": 2, "productThumb": 190, + "productPrice": "129.50", "purchaseStatus": 0, "receiverTel": "item-phone-not-allowed" } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 8354026..5a84550 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v15) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v14) error = %v", err) } @@ -432,9 +435,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3-v14) error = %v", err) - } else if applied != 12 { - t.Fatalf("Up(v3-v14) applied = %d, want 12", applied) + t.Fatalf("Up(v3-v15) error = %v", err) + } else if applied != 13 { + t.Fatalf("Up(v3-v15) applied = %d, want 13", applied) } } diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go index 2e5cf94..17d0a5e 100644 --- a/backend-api/internal/repository/sqlite/freight_repository.go +++ b/backend-api/internal/repository/sqlite/freight_repository.go @@ -318,10 +318,11 @@ func upsertFreightOrderItem( ctx, `INSERT INTO freight_order_items ( id, freight_order_id, external_item_id, title, product_spec, - sku, quantity, product_thumb_ref, purchase_status, + sku, quantity, product_thumb_ref, original_unit_price_minor, + original_currency, purchase_status, canonical_sha256, revision, is_present, first_sync_run_id, last_sync_run_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?) ON CONFLICT (freight_order_id, external_item_id) DO UPDATE SET title = excluded.title, @@ -329,6 +330,8 @@ func upsertFreightOrderItem( sku = excluded.sku, quantity = excluded.quantity, product_thumb_ref = excluded.product_thumb_ref, + original_unit_price_minor = excluded.original_unit_price_minor, + original_currency = excluded.original_currency, purchase_status = excluded.purchase_status, revision = CASE WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256 @@ -347,6 +350,8 @@ func upsertFreightOrderItem( item.SKU, nullableFreightQuantity(item.Quantity), nullableString(item.ProductThumbRef), + nullableInt64(item.OriginalUnitPriceMinor), + item.OriginalCurrency, nullableString(item.PurchaseStatus), item.CanonicalSHA256, runID, @@ -519,7 +524,8 @@ func (store *Store) GetFreightOrder( ctx, `SELECT id, freight_order_id, external_item_id, title, product_spec, - sku, quantity, product_thumb_ref, purchase_status, + sku, quantity, product_thumb_ref, original_unit_price_minor, + original_currency, purchase_status, canonical_sha256, revision, is_present, first_sync_run_id, last_sync_run_id, created_at, updated_at FROM freight_order_items @@ -678,6 +684,7 @@ func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) { func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) { var item domain.FreightOrderItem var quantity sql.NullInt64 + var originalUnitPriceMinor sql.NullInt64 var thumb, purchaseStatus sql.NullString var createdAt, updatedAt string err := scanner.Scan( @@ -689,6 +696,8 @@ func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) { &item.SKU, &quantity, &thumb, + &originalUnitPriceMinor, + &item.OriginalCurrency, &purchaseStatus, &item.CanonicalSHA256, &item.Revision, @@ -706,6 +715,9 @@ func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) { item.Quantity = &value } item.ProductThumbRef = optionalString(thumb) + if originalUnitPriceMinor.Valid { + item.OriginalUnitPriceMinor = &originalUnitPriceMinor.Int64 + } item.PurchaseStatus = optionalString(purchaseStatus) item.CreatedAt, err = parseTimestamp(createdAt) if err != nil { diff --git a/backend-api/internal/repository/sqlite/freight_repository_test.go b/backend-api/internal/repository/sqlite/freight_repository_test.go index b2e97db..fbc3454 100644 --- a/backend-api/internal/repository/sqlite/freight_repository_test.go +++ b/backend-api/internal/repository/sqlite/freight_repository_test.go @@ -57,6 +57,8 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { t.Fatalf("StartFreightSync() error = %v", err) } batch := freightBatch(910, "a", "b") + price := int64(12950) + batch.Orders[0].Items[0].OriginalUnitPriceMinor = &price if err := store.CompleteFreightSync( ctx, first, @@ -74,10 +76,16 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { if err != nil || len(detail.Items) != 2 { t.Fatalf("detail = %+v, error = %v", detail, err) } + if detail.Items[0].OriginalUnitPriceMinor == nil || + *detail.Items[0].OriginalUnitPriceMinor != price || + detail.Items[0].OriginalCurrency != domain.FreightCurrencyTWD { + t.Fatalf("item price metadata = %+v", detail.Items[0]) + } second := freightRun(904, userID, now.Add(time.Minute)) createAndStartFreightRun(t, store, second, "freight-key-2", "3") unchanged := freightBatch(920, "a", "b") + unchanged.Orders[0].Items[0].OriginalUnitPriceMinor = &price if err := store.CompleteFreightSync( ctx, second, @@ -96,6 +104,7 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { third := freightRun(905, userID, now.Add(2*time.Minute)) createAndStartFreightRun(t, store, third, "freight-key-3", "4") changed := freightBatch(930, "a", "c") + changed.Orders[0].Items[0].OriginalUnitPriceMinor = &price changed.Orders[0].CanonicalSHA256 = repeatHex("d") changed.Orders[0].Items[1].Title = "变化后的商品" changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e") @@ -113,10 +122,18 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { detail.Items[1].Revision != 2 { t.Fatalf("changed revisions = %+v", detail) } + if _, err := db.Exec( + `UPDATE freight_order_items SET original_unit_price_minor = NULL`, + ); err != nil { + t.Fatalf("clear metadata before rollback guard test: %v", err) + } runner, err := migration.New(db) if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("metadata migration down: %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("date sync migration down: %v", err) } @@ -251,6 +268,9 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess( } runner, _ := migration.New(db) + if err := runner.Down(ctx); err != nil { + t.Fatalf("metadata migration down: %v", err) + } if err := runner.Down(ctx); err == nil { t.Fatal("date sync migration down succeeded with retained watermark") } @@ -334,22 +354,24 @@ func freightBatch(index int, firstHash, secondHash string) domain.FreightImportB CanonicalSHA256: repeatHex("c"), Items: []domain.FreightImportItem{ { - ID: uuid(index + 1), - ExternalItemID: "88", - Title: "商品一", - ProductSpec: "黑色,L", - SKU: "BLACK-L", - Quantity: &quantityOne, - CanonicalSHA256: repeatHex(firstHash), + ID: uuid(index + 1), + ExternalItemID: "88", + Title: "商品一", + ProductSpec: "黑色,L", + SKU: "黑色,L", + Quantity: &quantityOne, + OriginalCurrency: domain.FreightCurrencyTWD, + CanonicalSHA256: repeatHex(firstHash), }, { - ID: uuid(index + 2), - ExternalItemID: "89", - Title: "商品二", - ProductSpec: "白色,M", - SKU: "WHITE-M", - Quantity: &quantityTwo, - CanonicalSHA256: repeatHex(secondHash), + ID: uuid(index + 2), + ExternalItemID: "89", + Title: "商品二", + ProductSpec: "白色,M", + SKU: "白色,M", + Quantity: &quantityTwo, + OriginalCurrency: domain.FreightCurrencyTWD, + CanonicalSHA256: repeatHex(secondHash), }, }, }}} diff --git a/backend-api/internal/repository/sqlite/procurement_repository.go b/backend-api/internal/repository/sqlite/procurement_repository.go index d2215d5..1b4f8c5 100644 --- a/backend-api/internal/repository/sqlite/procurement_repository.go +++ b/backend-api/internal/repository/sqlite/procurement_repository.go @@ -21,7 +21,8 @@ func (store *Store) GetProcurementSourceItem( `SELECT item.id, item.freight_order_id, item.external_item_id, item.title, item.product_spec, item.sku, item.quantity, - item.product_thumb_ref, item.purchase_status, + item.product_thumb_ref, item.original_unit_price_minor, + item.original_currency, item.purchase_status, item.canonical_sha256, item.revision, item.is_present, item.first_sync_run_id, item.last_sync_run_id, item.created_at, item.updated_at diff --git a/backend-api/internal/repository/sqlite/procurement_repository_test.go b/backend-api/internal/repository/sqlite/procurement_repository_test.go index 6203a69..6ba5cba 100644 --- a/backend-api/internal/repository/sqlite/procurement_repository_test.go +++ b/backend-api/internal/repository/sqlite/procurement_repository_test.go @@ -238,6 +238,9 @@ func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("metadata migration down: %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("date sync migration down: %v", err) } diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 6b7306e..7ac9afe 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -472,8 +472,10 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) { "", ) if detail.Code != http.StatusOK || - !strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) || - !strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) || + !strings.Contains(detail.Body.String(), `"sku":"黑色,L"`) || + !strings.Contains(detail.Body.String(), `"sku":"白色,M"`) || + !strings.Contains(detail.Body.String(), `"original_unit_price_minor":12950`) || + !strings.Contains(detail.Body.String(), `"original_currency":"TWD"`) || detail.Header().Get("Cache-Control") != "no-store" { t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body) } @@ -718,7 +720,7 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) { ) for _, required := range []string{ `"title":"商品一"`, - `"sku":"BLACK-L"`, + `"sku":"黑色,L"`, `"quantity":1`, `"image_asset_id":"` + assetBody.ID + `"`, `"description":"ERP 规格:黑色,L"`, @@ -873,6 +875,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("metadata migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("date sync migration down: %v", err) } @@ -1263,6 +1268,8 @@ func (staticFreightSource) QueryOrder( created := "2026-07-28 08:00" quantityOne := 1 quantityTwo := 2 + priceOne := int64(12950) + priceTwo := int64(8800) return domain.FreightSourceBatch{ SchemaVersion: 1, Query: domain.FreightSourceQuery{ @@ -1275,18 +1282,22 @@ func (staticFreightSource) QueryOrder( SourceCreatedAt: &created, Items: []domain.FreightSourceItem{ { - ExternalItemID: "88", - Title: "商品一", - ProductSpec: "黑色,L", - SKU: "BLACK-L", - Quantity: &quantityOne, + ExternalItemID: "88", + Title: "商品一", + ProductSpec: "黑色,L", + SKU: "黑色,L", + Quantity: &quantityOne, + OriginalUnitPriceMinor: &priceOne, + OriginalCurrency: domain.FreightCurrencyTWD, }, { - ExternalItemID: "89", - Title: "商品二", - ProductSpec: "白色,M", - SKU: "WHITE-M", - Quantity: &quantityTwo, + ExternalItemID: "89", + Title: "商品二", + ProductSpec: "白色,M", + SKU: "白色,M", + Quantity: &quantityTwo, + OriginalUnitPriceMinor: &priceTwo, + OriginalCurrency: domain.FreightCurrencyTWD, }, }, }}, diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index b06f106..1dd2a74 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) { if err != nil { t.Fatalf("migration.New() after review error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("metadata migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("date sync migration down: %v", err) } @@ -736,8 +739,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 != 6 { - t.Fatalf("restored migrations = %d, want 6", applied) + } else if applied != 7 { + t.Fatalf("restored migrations = %d, want 7", applied) } completePayload := fmt.Sprintf( @@ -1426,6 +1429,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("metadata migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("date sync migration down: %v", err) } diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go index 971e528..16855b4 100644 --- a/backend-api/internal/transport/httpapi/freight_handlers.go +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -206,17 +206,19 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) { items := make([]gin.H, 0, len(detail.Items)) for _, item := range detail.Items { items = append(items, gin.H{ - "id": item.ID, - "external_item_id": item.ExternalItemID, - "title": item.Title, - "product_spec": item.ProductSpec, - "sku": item.SKU, - "quantity": item.Quantity, - "product_thumb_ref": item.ProductThumbRef, - "purchase_status": item.PurchaseStatus, - "revision": item.Revision, - "canonical_sha256": item.CanonicalSHA256, - "updated_at": formatTime(item.UpdatedAt), + "id": item.ID, + "external_item_id": item.ExternalItemID, + "title": item.Title, + "product_spec": item.ProductSpec, + "sku": item.SKU, + "quantity": item.Quantity, + "product_thumb_ref": item.ProductThumbRef, + "original_unit_price_minor": item.OriginalUnitPriceMinor, + "original_currency": item.OriginalCurrency, + "purchase_status": item.PurchaseStatus, + "revision": item.Revision, + "canonical_sha256": item.CanonicalSHA256, + "updated_at": formatTime(item.UpdatedAt), }) } ctx.Header("Cache-Control", "no-store") diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index bb3adec..0d3801c 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -150,15 +150,17 @@ type FreightOrder struct { } type FreightOrderItem struct { - ID string - ExternalItemID string - Title string - ProductSpec string - SKU string - Quantity int - ProductThumbRef string - PurchaseStatus string - Revision int + ID string + ExternalItemID string + Title string + ProductSpec string + SKU string + Quantity int + ProductThumbRef string + OriginalUnitPriceMinor *int64 + OriginalCurrency string + PurchaseStatus string + Revision int } type FreightOrderDetail struct { diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index e9bbb90..9ae571a 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -160,14 +160,16 @@ func (adapter *UsecaseAdapter) GetFreightOrder( items := make([]FreightItemReview, 0, len(detail.Items)) for _, item := range detail.Items { view := FreightOrderItem{ - ID: item.ID, - ExternalItemID: item.ExternalItemID, - Title: item.Title, - ProductSpec: item.ProductSpec, - SKU: item.SKU, - ProductThumbRef: stringValue(item.ProductThumbRef), - PurchaseStatus: stringValue(item.PurchaseStatus), - Revision: item.Revision, + ID: item.ID, + ExternalItemID: item.ExternalItemID, + Title: item.Title, + ProductSpec: item.ProductSpec, + SKU: item.SKU, + ProductThumbRef: stringValue(item.ProductThumbRef), + OriginalUnitPriceMinor: item.OriginalUnitPriceMinor, + OriginalCurrency: item.OriginalCurrency, + PurchaseStatus: stringValue(item.PurchaseStatus), + Revision: item.Revision, } if item.Quantity != nil { view.Quantity = *item.Quantity diff --git a/backend-api/internal/usecase/freight_service.go b/backend-api/internal/usecase/freight_service.go index 619954d..0811a77 100644 --- a/backend-api/internal/usecase/freight_service.go +++ b/backend-api/internal/usecase/freight_service.go @@ -728,6 +728,9 @@ func (service *FreightService) normalize( !validBytes(sourceItem.ProductSpec, 1024) || !validBytes(sourceItem.SKU, 512) || !validOptional(sourceItem.ProductThumbRef, 512) || + sourceItem.OriginalCurrency != domain.FreightCurrencyTWD || + (sourceItem.OriginalUnitPriceMinor != nil && + *sourceItem.OriginalUnitPriceMinor < 0) || !validOptional(sourceItem.PurchaseStatus, 128) || (sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) { return domain.FreightImportBatch{}, errors.New("invalid freight item fields") @@ -737,26 +740,32 @@ func (service *FreightService) normalize( return domain.FreightImportBatch{}, err } item := domain.FreightImportItem{ - ID: itemID, - ExternalItemID: itemExternalID, - Title: sourceItem.Title, - ProductSpec: sourceItem.ProductSpec, - SKU: sourceItem.SKU, - Quantity: sourceItem.Quantity, - ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef), - PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus), + ID: itemID, + ExternalItemID: itemExternalID, + Title: sourceItem.Title, + ProductSpec: sourceItem.ProductSpec, + SKU: sourceItem.SKU, + Quantity: sourceItem.Quantity, + ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef), + OriginalUnitPriceMinor: sourceItem.OriginalUnitPriceMinor, + OriginalCurrency: sourceItem.OriginalCurrency, + PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus), } item.CanonicalSHA256 = hashJSON(struct { - ExternalItemID string `json:"external_item_id"` - Title string `json:"title"` - ProductSpec string `json:"product_spec"` - SKU string `json:"sku"` - Quantity *int `json:"quantity"` - ProductThumbRef *string `json:"product_thumb_ref"` - PurchaseStatus *string `json:"purchase_status"` + ExternalItemID string `json:"external_item_id"` + Title string `json:"title"` + ProductSpec string `json:"product_spec"` + SKU string `json:"sku"` + Quantity *int `json:"quantity"` + ProductThumbRef *string `json:"product_thumb_ref"` + OriginalUnitPriceMinor *int64 `json:"original_unit_price_minor"` + OriginalCurrency string `json:"original_currency"` + PurchaseStatus *string `json:"purchase_status"` }{ item.ExternalItemID, item.Title, item.ProductSpec, item.SKU, - item.Quantity, item.ProductThumbRef, item.PurchaseStatus, + item.Quantity, item.ProductThumbRef, + item.OriginalUnitPriceMinor, item.OriginalCurrency, + item.PurchaseStatus, }) order.Items = append(order.Items, item) } diff --git a/backend-api/internal/usecase/freight_service_test.go b/backend-api/internal/usecase/freight_service_test.go index 805a5a3..322f9d0 100644 --- a/backend-api/internal/usecase/freight_service_test.go +++ b/backend-api/internal/usecase/freight_service_test.go @@ -33,6 +33,19 @@ func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) { second.Orders[0].CanonicalSHA256, ) } + changed := validFreightSource() + changedPrice := *changed.Orders[0].Items[0].OriginalUnitPriceMinor + 1 + changed.Orders[0].Items[0].OriginalUnitPriceMinor = &changedPrice + changedBatch, err := firstService.normalize(changed) + if err != nil { + t.Fatalf("changed price normalize error = %v", err) + } + if first.Orders[0].CanonicalSHA256 == + changedBatch.Orders[0].CanonicalSHA256 || + first.Orders[0].Items[0].CanonicalSHA256 == + changedBatch.Orders[0].Items[0].CanonicalSHA256 { + t.Fatal("canonical hash ignored original unit price") + } } func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime( @@ -466,6 +479,7 @@ func validFreightSource() domain.FreightSourceBatch { thumb := "190" itemPurchaseStatus := "0" quantity := 2 + originalUnitPriceMinor := int64(12950) canceled := false return domain.FreightSourceBatch{ SchemaVersion: 1, @@ -481,13 +495,15 @@ func validFreightSource() domain.FreightSourceBatch { PurchaseStatus: &purchaseStatus, IsCanceled: &canceled, Items: []domain.FreightSourceItem{{ - ExternalItemID: "88", - Title: "商品", - ProductSpec: "黑色,L", - SKU: "BLACK-L", - Quantity: &quantity, - ProductThumbRef: &thumb, - PurchaseStatus: &itemPurchaseStatus, + ExternalItemID: "88", + Title: "商品", + ProductSpec: "黑色,L", + SKU: "黑色,L", + Quantity: &quantity, + ProductThumbRef: &thumb, + OriginalUnitPriceMinor: &originalUnitPriceMinor, + OriginalCurrency: domain.FreightCurrencyTWD, + PurchaseStatus: &itemPurchaseStatus, }}, }}, } diff --git a/backend-api/migrations/00015_freight_item_metadata.sql b/backend-api/migrations/00015_freight_item_metadata.sql new file mode 100644 index 0000000..74fe57a --- /dev/null +++ b/backend-api/migrations/00015_freight_item_metadata.sql @@ -0,0 +1,29 @@ +-- +goose Up +ALTER TABLE freight_order_items + ADD COLUMN original_unit_price_minor INTEGER + CHECK (original_unit_price_minor IS NULL OR original_unit_price_minor >= 0); + +ALTER TABLE freight_order_items + ADD COLUMN original_currency TEXT NOT NULL DEFAULT 'TWD' + CHECK (original_currency = 'TWD'); + +-- +goose Down +CREATE TEMP TABLE freight_item_metadata_v15_down_guard ( + allowed INTEGER NOT NULL CHECK (allowed = 1) +); + +INSERT INTO freight_item_metadata_v15_down_guard (allowed) +SELECT CASE + WHEN EXISTS ( + SELECT 1 + FROM freight_order_items + WHERE original_unit_price_minor IS NOT NULL + ) + THEN 0 + ELSE 1 +END; + +DROP TABLE freight_item_metadata_v15_down_guard; + +ALTER TABLE freight_order_items DROP COLUMN original_currency; +ALTER TABLE freight_order_items DROP COLUMN original_unit_price_minor; diff --git a/docs/api.md b/docs/api.md index a1c94f8..39307da 100644 --- a/docs/api.md +++ b/docs/api.md @@ -186,9 +186,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容 "external_item_id": "880011", "title": "商品标题", "product_spec": "灰色,2XL", - "sku": "原始 SKU", + "sku": "灰色,2XL", "quantity": 2, "product_thumb_ref": "190000000", + "original_unit_price_minor": 12950, + "original_currency": "TWD", "purchase_status": "0" }] }] @@ -198,6 +200,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容 不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始 对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。 +商品 `product_spec` 和采购使用的 `sku` 均来自 ERP `productSpec`,不得静默回退 +`sku/variationSku`。`productPrice` 严格转换为 `original_unit_price_minor`;缺失价格为 +`null`,币种固定为 `TWD`。`product_thumb_ref` 来自商品 `productThumb`,存在时必须是 +规范化正整数,不能使用货运单 ID、商品明细 ID 或完整 URL。 + ### Go ERP 会话(T-230、T-236) `/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步 diff --git a/docs/current-state.md b/docs/current-state.md index 8a5d451..df3dcff 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -198,6 +198,10 @@ | `docs/tasks/T-233.md` | DONE | 修复预检错误映射并增加受控 ERP 诊断日志 | | `docs/tasks/T-234.md` | DONE | 在受控诊断模式输出 OCR 验证码文本 | | `docs/tasks/T-235.md` | DONE | 修复顺运宝登录后用户会话校验协议 | +| `docs/tasks/T-236.md` | DONE | 验证码错误时重新取图和 OCR,最多额外重试 6 次 | +| `docs/tasks/T-237.md` | DONE | 完整单号在 55 秒预算内同步导入并可靠收敛终态 | +| `docs/tasks/T-238.md` | DONE | 兼容顺运宝详情分钟精度时间 | +| `docs/tasks/T-239.md` | DONE | 冻结货运规格 SKU、TWD 原始单价和图片引用契约 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -209,12 +213,11 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-235 ERP 契约、货运存储、采购需求生成、日期增量同步、Go +- 已完成:另含 T-220 至 T-239 ERP 契约、货运存储、采购需求生成、日期增量同步、Go 直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector - 清理和受控本地凭证加载。 + 清理、受控本地凭证加载、同步单号导入、分钟时间兼容和货运商品元数据契约。 - 进行中:无。 -- 下一步:使用 `start-backend.bat --erp-debug` 重启 API 并重新执行受控单号 smoke;确认用户 - 校验成功后继续观察 listTotal/list/detail 请求和同步结果。 +- 下一步:实现商品 `productThumb` 图片的后端受控下载、本地保存和鉴权访问。 ## 当前可运行内容 diff --git a/docs/tasks/T-239.md b/docs/tasks/T-239.md index c7db137..cf224d2 100644 --- a/docs/tasks/T-239.md +++ b/docs/tasks/T-239.md @@ -4,7 +4,7 @@ title: 冻结货运商品规格、原始价格与图片引用契约 phase: 2 deps: - T-238 -status: TODO +status: DONE created: 2026-07-29 context_ref: 01be7e7 work_branch: null @@ -55,13 +55,13 @@ ERP `sku/variationSku`,与本项目需要按颜色、尺码硬匹配的 `produ ## 验收要点 -- [ ] `productSpec` 同时成为货运商品的规格和采购 SKU,缺失时不使用其他 ERP SKU 猜测。 -- [ ] `productPrice` 的合法整数、小数和数字字符串精确转换为 TWD 最小单位。 -- [ ] 缺失价格为 `NULL`;负数、指数、超过两位小数和越界值拒绝为 ERP 协议错误。 -- [ ] `productThumb` 仅接受可规范化的正整数引用,且不与货运单 ID 混用。 -- [ ] 金额、币种和新 SKU 进入 canonical hash,并通过 SQLite 往返和 revision 测试。 -- [ ] API/Web 适配层能读取新增字段;既有采购快照和任务不可变语义不回归。 -- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 +- [x] `productSpec` 同时成为货运商品的规格和采购 SKU,缺失时不使用其他 ERP SKU 猜测。 +- [x] `productPrice` 的合法整数、小数和数字字符串精确转换为 TWD 最小单位。 +- [x] 缺失价格为 `NULL`;负数、指数、超过两位小数和越界值拒绝为 ERP 协议错误。 +- [x] `productThumb` 仅接受可规范化的正整数引用,且不与货运单 ID 混用。 +- [x] 金额、币种和新 SKU 进入 canonical hash,并通过 SQLite 往返和 revision 测试。 +- [x] API/Web 适配层能读取新增字段;既有采购快照和任务不可变语义不回归。 +- [x] 标准 Go 测试、race、vet 和三个入口构建通过。 ## 边界 @@ -73,3 +73,7 @@ ERP `sku/variationSku`,与本项目需要按颜色、尺码硬匹配的 `produ - 2026-07-29:创建任务。确认图片 URL 的查询参数必须来自商品 `productThumb`,不能使用 货运单或商品明细 ID;金额使用定点最小单位,图片网络获取拆分到后续任务。 +- 2026-07-29:增加 migration 15 和货运领域/API/Web 字段;normalizer 以 `productSpec` + 同时生成规格和采购 SKU,严格解析 TWD 原始单价及正整数图片引用。金额进入商品与货运单 + canonical hash,SQLite 往返、revision、采购不可变快照和脱敏 API 回归通过。标准 Go + 测试、race、vet 及三个入口构建均通过;真实 ERP JSON 保持未跟踪。