feat(t239): persist freight item metadata

This commit is contained in:
QiuSW
2026-07-29 14:54:58 +08:00
parent 42006acedb
commit 3bd9726ddc
23 changed files with 476 additions and 158 deletions
+39 -32
View File
@@ -6,6 +6,7 @@ const (
FreightSourceShunyunbao = "SHUNYUNBAO" FreightSourceShunyunbao = "SHUNYUNBAO"
FreightSyncOrderNumber = "ORDER_NUMBER" FreightSyncOrderNumber = "ORDER_NUMBER"
FreightSyncCreatedRange = "CREATED_RANGE" FreightSyncCreatedRange = "CREATED_RANGE"
FreightCurrencyTWD = "TWD"
) )
type FreightSyncStatus string type FreightSyncStatus string
@@ -58,22 +59,24 @@ type FreightOrder struct {
} }
type FreightOrderItem struct { type FreightOrderItem struct {
ID string ID string
FreightOrderID string FreightOrderID string
ExternalItemID string ExternalItemID string
Title string Title string
ProductSpec string ProductSpec string
SKU string SKU string
Quantity *int Quantity *int
ProductThumbRef *string ProductThumbRef *string
PurchaseStatus *string OriginalUnitPriceMinor *int64
CanonicalSHA256 string OriginalCurrency string
Revision int PurchaseStatus *string
IsPresent bool CanonicalSHA256 string
FirstSyncRunID string Revision int
LastSyncRunID string IsPresent bool
CreatedAt time.Time FirstSyncRunID string
UpdatedAt time.Time LastSyncRunID string
CreatedAt time.Time
UpdatedAt time.Time
} }
type FreightOrderDetail struct { type FreightOrderDetail struct {
@@ -114,13 +117,15 @@ type FreightSourceOrder struct {
} }
type FreightSourceItem struct { type FreightSourceItem struct {
ExternalItemID string `json:"external_item_id"` ExternalItemID string `json:"external_item_id"`
Title string `json:"title"` Title string `json:"title"`
ProductSpec string `json:"product_spec"` ProductSpec string `json:"product_spec"`
SKU string `json:"sku"` SKU string `json:"sku"`
Quantity *int `json:"quantity"` Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"` ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"` OriginalUnitPriceMinor *int64 `json:"original_unit_price_minor"`
OriginalCurrency string `json:"original_currency"`
PurchaseStatus *string `json:"purchase_status"`
} }
type FreightImportBatch struct { type FreightImportBatch struct {
@@ -142,13 +147,15 @@ type FreightImportOrder struct {
} }
type FreightImportItem struct { type FreightImportItem struct {
ID string ID string
ExternalItemID string ExternalItemID string
Title string Title string
ProductSpec string ProductSpec string
SKU string SKU string
Quantity *int Quantity *int
ProductThumbRef *string ProductThumbRef *string
PurchaseStatus *string OriginalUnitPriceMinor *int64
CanonicalSHA256 string OriginalCurrency string
PurchaseStatus *string
CanonicalSHA256 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 != 14 { } else if applied != 15 {
t.Fatalf("initial Up() applied = %d, want 14", applied) 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 { if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v14) error = %v", err) t.Fatalf("initial Down(v14) error = %v", err)
@@ -71,9 +74,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-v14) over historical data error = %v", err) t.Fatalf("Up(v5-v15) over historical data error = %v", err)
} else if applied != 10 { } else if applied != 11 {
t.Fatalf("Up(v5-v14) applied = %d, want 10", applied) 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) assertClaimsHistory(t, db, true)
@@ -133,9 +141,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-v14) error = %v", err) t.Fatalf("final Up(v4-v15) error = %v", err)
} else if applied != 11 { } else if applied != 12 {
t.Fatalf("final Up(v4-v14) applied = %d, want 11", applied) t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied)
} }
assertClaimsHistory(t, db, true) assertClaimsHistory(t, db, true)
} }
@@ -371,6 +379,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(v15) error = %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v14) error = %v", err) t.Fatalf("Down(v14) 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 != 14 { if applied != 15 {
t.Fatalf("Up() applied = %d, want 14", applied) t.Fatalf("Up() applied = %d, want 15", applied)
} }
assertStatuses(t, runner, map[int64]bool{ assertStatuses(t, runner, map[int64]bool{
1: true, 1: true,
@@ -45,6 +45,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
12: true, 12: true,
13: true, 13: true,
14: true, 14: true,
15: true,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -72,7 +73,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
11: true, 11: true,
12: true, 12: true,
13: true, 13: true,
14: false, 14: true,
15: false,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -97,6 +99,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
12: true, 12: true,
13: true, 13: true,
14: true, 14: true,
15: true,
}) })
} }
@@ -156,21 +156,109 @@ func normalizeItem(value map[string]any) (domain.FreightSourceItem, error) {
if title == "" { if title == "" {
title = text(value["detailProductName"]) title = text(value["detailProductName"])
} }
sku := text(value["sku"]) productSpec := text(value["productSpec"])
if sku == "" { productThumbRef, err := optionalExternalID(value["productThumb"])
sku = text(value["variationSku"]) if err != nil {
return domain.FreightSourceItem{}, err
}
originalUnitPriceMinor, err := priceMinor(value["productPrice"])
if err != nil {
return domain.FreightSourceItem{}, err
} }
return domain.FreightSourceItem{ return domain.FreightSourceItem{
ExternalItemID: externalItemID, ExternalItemID: externalItemID,
Title: title, Title: title,
ProductSpec: text(value["productSpec"]), ProductSpec: productSpec,
SKU: sku, SKU: productSpec,
Quantity: positiveInt(value["productQty"]), Quantity: positiveInt(value["productQty"]),
ProductThumbRef: optionalScalar(value["productThumb"]), ProductThumbRef: productThumbRef,
PurchaseStatus: optionalScalar(value["purchaseStatus"]), OriginalUnitPriceMinor: originalUnitPriceMinor,
OriginalCurrency: domain.FreightCurrencyTWD,
PurchaseStatus: optionalScalar(value["purchaseStatus"]),
}, nil }, 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) { func externalID(value any) (string, error) {
var normalized uint64 var normalized uint64
switch typed := value.(type) { switch typed := value.(type) {
@@ -86,8 +86,11 @@ func TestNormalizeFreightFixtureUsesOnlyAllowlist(t *testing.T) {
order := result.Orders[0] order := result.Orders[0]
if order.ExternalStockID != "12" || order.SourceCode != "SANITIZED-CODE-12" || if order.ExternalStockID != "12" || order.SourceCode != "SANITIZED-CODE-12" ||
order.ShopName == nil || *order.ShopName != "Sanitized shop" || order.ShopName == nil || *order.ShopName != "Sanitized shop" ||
order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "BLACK-L" || order.Items[0].ExternalItemID != "88" || order.Items[0].SKU != "Black,L" ||
order.Items[0].Quantity == nil || *order.Items[0].Quantity != 2 { 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) t.Fatalf("allowlist result = %#v", order)
} }
encoded, err := json.Marshal(result) 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) { func TestNormalizeFreightNeverLeaksRawValuesInErrors(t *testing.T) {
_, err := NormalizeFreightResult( _, err := NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber}, RawQuery{Mode: domain.FreightSyncOrderNumber},
@@ -51,7 +51,7 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
t.Fatalf("detail query = %q", request.URL.RawQuery) t.Fatalf("detail query = %q", request.URL.RawQuery)
} }
assertDetailPayload(t, request, []uint64{12}) 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: default:
writer.WriteHeader(http.StatusNotFound) writer.WriteHeader(http.StatusNotFound)
} }
@@ -67,8 +67,10 @@ func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber || if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber ||
len(result.Orders) != 1 || result.Orders[0].ExternalStockID != "12" || len(result.Orders) != 1 || result.Orders[0].ExternalStockID != "12" ||
result.Orders[0].ShopName == nil || *result.Orders[0].ShopName != "测试店铺" || result.Orders[0].ShopName == nil || *result.Orders[0].ShopName != "测试店铺" ||
len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "BLACK-L" || len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "黑色,L" ||
result.Orders[0].Items[1].SKU != "BLACK-XL" { 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) t.Fatalf("QueryOrder() = %#v", result)
} }
encoded, err := json.Marshal(result) encoded, err := json.Marshal(result)
@@ -32,6 +32,7 @@
"sku": "BLACK-L", "sku": "BLACK-L",
"productQty": 2, "productQty": 2,
"productThumb": 190, "productThumb": 190,
"productPrice": "129.50",
"purchaseStatus": 0, "purchaseStatus": 0,
"receiverTel": "item-phone-not-allowed" "receiverTel": "item-phone-not-allowed"
} }
@@ -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(v15) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v14) error = %v", err) t.Fatalf("Down(v14) error = %v", err)
} }
@@ -432,9 +435,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback") t.Fatal("purchase_tasks was lost during auth migration rollback")
} }
if applied, err := runner.Up(context.Background()); err != nil { if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v14) error = %v", err) t.Fatalf("Up(v3-v15) error = %v", err)
} else if applied != 12 { } else if applied != 13 {
t.Fatalf("Up(v3-v14) applied = %d, want 12", applied) t.Fatalf("Up(v3-v15) applied = %d, want 13", applied)
} }
} }
@@ -318,10 +318,11 @@ func upsertFreightOrderItem(
ctx, ctx,
`INSERT INTO freight_order_items ( `INSERT INTO freight_order_items (
id, freight_order_id, external_item_id, title, product_spec, 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, canonical_sha256, revision, is_present, first_sync_run_id,
last_sync_run_id, created_at, updated_at last_sync_run_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)
ON CONFLICT (freight_order_id, external_item_id) ON CONFLICT (freight_order_id, external_item_id)
DO UPDATE SET DO UPDATE SET
title = excluded.title, title = excluded.title,
@@ -329,6 +330,8 @@ func upsertFreightOrderItem(
sku = excluded.sku, sku = excluded.sku,
quantity = excluded.quantity, quantity = excluded.quantity,
product_thumb_ref = excluded.product_thumb_ref, 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, purchase_status = excluded.purchase_status,
revision = CASE revision = CASE
WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256 WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256
@@ -347,6 +350,8 @@ func upsertFreightOrderItem(
item.SKU, item.SKU,
nullableFreightQuantity(item.Quantity), nullableFreightQuantity(item.Quantity),
nullableString(item.ProductThumbRef), nullableString(item.ProductThumbRef),
nullableInt64(item.OriginalUnitPriceMinor),
item.OriginalCurrency,
nullableString(item.PurchaseStatus), nullableString(item.PurchaseStatus),
item.CanonicalSHA256, item.CanonicalSHA256,
runID, runID,
@@ -519,7 +524,8 @@ func (store *Store) GetFreightOrder(
ctx, ctx,
`SELECT `SELECT
id, freight_order_id, external_item_id, title, product_spec, 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, canonical_sha256, revision, is_present, first_sync_run_id,
last_sync_run_id, created_at, updated_at last_sync_run_id, created_at, updated_at
FROM freight_order_items FROM freight_order_items
@@ -678,6 +684,7 @@ func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) {
func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) { func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
var item domain.FreightOrderItem var item domain.FreightOrderItem
var quantity sql.NullInt64 var quantity sql.NullInt64
var originalUnitPriceMinor sql.NullInt64
var thumb, purchaseStatus sql.NullString var thumb, purchaseStatus sql.NullString
var createdAt, updatedAt string var createdAt, updatedAt string
err := scanner.Scan( err := scanner.Scan(
@@ -689,6 +696,8 @@ func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
&item.SKU, &item.SKU,
&quantity, &quantity,
&thumb, &thumb,
&originalUnitPriceMinor,
&item.OriginalCurrency,
&purchaseStatus, &purchaseStatus,
&item.CanonicalSHA256, &item.CanonicalSHA256,
&item.Revision, &item.Revision,
@@ -706,6 +715,9 @@ func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
item.Quantity = &value item.Quantity = &value
} }
item.ProductThumbRef = optionalString(thumb) item.ProductThumbRef = optionalString(thumb)
if originalUnitPriceMinor.Valid {
item.OriginalUnitPriceMinor = &originalUnitPriceMinor.Int64
}
item.PurchaseStatus = optionalString(purchaseStatus) item.PurchaseStatus = optionalString(purchaseStatus)
item.CreatedAt, err = parseTimestamp(createdAt) item.CreatedAt, err = parseTimestamp(createdAt)
if err != nil { if err != nil {
@@ -57,6 +57,8 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
t.Fatalf("StartFreightSync() error = %v", err) t.Fatalf("StartFreightSync() error = %v", err)
} }
batch := freightBatch(910, "a", "b") batch := freightBatch(910, "a", "b")
price := int64(12950)
batch.Orders[0].Items[0].OriginalUnitPriceMinor = &price
if err := store.CompleteFreightSync( if err := store.CompleteFreightSync(
ctx, ctx,
first, first,
@@ -74,10 +76,16 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
if err != nil || len(detail.Items) != 2 { if err != nil || len(detail.Items) != 2 {
t.Fatalf("detail = %+v, error = %v", detail, err) 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)) second := freightRun(904, userID, now.Add(time.Minute))
createAndStartFreightRun(t, store, second, "freight-key-2", "3") createAndStartFreightRun(t, store, second, "freight-key-2", "3")
unchanged := freightBatch(920, "a", "b") unchanged := freightBatch(920, "a", "b")
unchanged.Orders[0].Items[0].OriginalUnitPriceMinor = &price
if err := store.CompleteFreightSync( if err := store.CompleteFreightSync(
ctx, ctx,
second, second,
@@ -96,6 +104,7 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
third := freightRun(905, userID, now.Add(2*time.Minute)) third := freightRun(905, userID, now.Add(2*time.Minute))
createAndStartFreightRun(t, store, third, "freight-key-3", "4") createAndStartFreightRun(t, store, third, "freight-key-3", "4")
changed := freightBatch(930, "a", "c") changed := freightBatch(930, "a", "c")
changed.Orders[0].Items[0].OriginalUnitPriceMinor = &price
changed.Orders[0].CanonicalSHA256 = repeatHex("d") changed.Orders[0].CanonicalSHA256 = repeatHex("d")
changed.Orders[0].Items[1].Title = "变化后的商品" changed.Orders[0].Items[1].Title = "变化后的商品"
changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e") changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e")
@@ -113,10 +122,18 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
detail.Items[1].Revision != 2 { detail.Items[1].Revision != 2 {
t.Fatalf("changed revisions = %+v", detail) 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) runner, err := migration.New(db)
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("metadata migration down: %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("date sync migration down: %v", err) t.Fatalf("date sync migration down: %v", err)
} }
@@ -251,6 +268,9 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
} }
runner, _ := migration.New(db) 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 { if err := runner.Down(ctx); err == nil {
t.Fatal("date sync migration down succeeded with retained watermark") 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"), CanonicalSHA256: repeatHex("c"),
Items: []domain.FreightImportItem{ Items: []domain.FreightImportItem{
{ {
ID: uuid(index + 1), ID: uuid(index + 1),
ExternalItemID: "88", ExternalItemID: "88",
Title: "商品一", Title: "商品一",
ProductSpec: "黑色,L", ProductSpec: "黑色,L",
SKU: "BLACK-L", SKU: "黑色,L",
Quantity: &quantityOne, Quantity: &quantityOne,
CanonicalSHA256: repeatHex(firstHash), OriginalCurrency: domain.FreightCurrencyTWD,
CanonicalSHA256: repeatHex(firstHash),
}, },
{ {
ID: uuid(index + 2), ID: uuid(index + 2),
ExternalItemID: "89", ExternalItemID: "89",
Title: "商品二", Title: "商品二",
ProductSpec: "白色,M", ProductSpec: "白色,M",
SKU: "WHITE-M", SKU: "白色,M",
Quantity: &quantityTwo, Quantity: &quantityTwo,
CanonicalSHA256: repeatHex(secondHash), OriginalCurrency: domain.FreightCurrencyTWD,
CanonicalSHA256: repeatHex(secondHash),
}, },
}, },
}}} }}}
@@ -21,7 +21,8 @@ func (store *Store) GetProcurementSourceItem(
`SELECT `SELECT
item.id, item.freight_order_id, item.external_item_id, item.id, item.freight_order_id, item.external_item_id,
item.title, item.product_spec, item.sku, item.quantity, 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.canonical_sha256, item.revision, item.is_present,
item.first_sync_run_id, item.last_sync_run_id, item.first_sync_run_id, item.last_sync_run_id,
item.created_at, item.updated_at item.created_at, item.updated_at
@@ -238,6 +238,9 @@ 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.Fatalf("metadata migration down: %v", err)
}
if err := runner.Down(ctx); err != nil { if err := runner.Down(ctx); err != nil {
t.Fatalf("date sync migration down: %v", err) t.Fatalf("date sync migration down: %v", err)
} }
@@ -472,8 +472,10 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
"", "",
) )
if detail.Code != http.StatusOK || if detail.Code != http.StatusOK ||
!strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) || !strings.Contains(detail.Body.String(), `"sku":"黑色,L"`) ||
!strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) || !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" { detail.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body) 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{ for _, required := range []string{
`"title":"商品一"`, `"title":"商品一"`,
`"sku":"BLACK-L"`, `"sku":"黑色,L"`,
`"quantity":1`, `"quantity":1`,
`"image_asset_id":"` + assetBody.ID + `"`, `"image_asset_id":"` + assetBody.ID + `"`,
`"description":"ERP 规格:黑色,L"`, `"description":"ERP 规格:黑色,L"`,
@@ -873,6 +875,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("metadata migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("date sync migration down: %v", err) t.Fatalf("date sync migration down: %v", err)
} }
@@ -1263,6 +1268,8 @@ func (staticFreightSource) QueryOrder(
created := "2026-07-28 08:00" created := "2026-07-28 08:00"
quantityOne := 1 quantityOne := 1
quantityTwo := 2 quantityTwo := 2
priceOne := int64(12950)
priceTwo := int64(8800)
return domain.FreightSourceBatch{ return domain.FreightSourceBatch{
SchemaVersion: 1, SchemaVersion: 1,
Query: domain.FreightSourceQuery{ Query: domain.FreightSourceQuery{
@@ -1275,18 +1282,22 @@ func (staticFreightSource) QueryOrder(
SourceCreatedAt: &created, SourceCreatedAt: &created,
Items: []domain.FreightSourceItem{ Items: []domain.FreightSourceItem{
{ {
ExternalItemID: "88", ExternalItemID: "88",
Title: "商品一", Title: "商品一",
ProductSpec: "黑色,L", ProductSpec: "黑色,L",
SKU: "BLACK-L", SKU: "黑色,L",
Quantity: &quantityOne, Quantity: &quantityOne,
OriginalUnitPriceMinor: &priceOne,
OriginalCurrency: domain.FreightCurrencyTWD,
}, },
{ {
ExternalItemID: "89", ExternalItemID: "89",
Title: "商品二", Title: "商品二",
ProductSpec: "白色,M", ProductSpec: "白色,M",
SKU: "WHITE-M", SKU: "白色,M",
Quantity: &quantityTwo, Quantity: &quantityTwo,
OriginalUnitPriceMinor: &priceTwo,
OriginalCurrency: domain.FreightCurrencyTWD,
}, },
}, },
}}, }},
@@ -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("metadata migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("date sync migration down: %v", err) 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 { 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 != 6 { } else if applied != 7 {
t.Fatalf("restored migrations = %d, want 6", applied) t.Fatalf("restored migrations = %d, want 7", applied)
} }
completePayload := fmt.Sprintf( completePayload := fmt.Sprintf(
@@ -1426,6 +1429,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("metadata migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("date sync migration down: %v", err) t.Fatalf("date sync migration down: %v", err)
} }
@@ -206,17 +206,19 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
items := make([]gin.H, 0, len(detail.Items)) items := make([]gin.H, 0, len(detail.Items))
for _, item := range detail.Items { for _, item := range detail.Items {
items = append(items, gin.H{ items = append(items, gin.H{
"id": item.ID, "id": item.ID,
"external_item_id": item.ExternalItemID, "external_item_id": item.ExternalItemID,
"title": item.Title, "title": item.Title,
"product_spec": item.ProductSpec, "product_spec": item.ProductSpec,
"sku": item.SKU, "sku": item.SKU,
"quantity": item.Quantity, "quantity": item.Quantity,
"product_thumb_ref": item.ProductThumbRef, "product_thumb_ref": item.ProductThumbRef,
"purchase_status": item.PurchaseStatus, "original_unit_price_minor": item.OriginalUnitPriceMinor,
"revision": item.Revision, "original_currency": item.OriginalCurrency,
"canonical_sha256": item.CanonicalSHA256, "purchase_status": item.PurchaseStatus,
"updated_at": formatTime(item.UpdatedAt), "revision": item.Revision,
"canonical_sha256": item.CanonicalSHA256,
"updated_at": formatTime(item.UpdatedAt),
}) })
} }
ctx.Header("Cache-Control", "no-store") ctx.Header("Cache-Control", "no-store")
+11 -9
View File
@@ -150,15 +150,17 @@ type FreightOrder struct {
} }
type FreightOrderItem struct { type FreightOrderItem struct {
ID string ID string
ExternalItemID string ExternalItemID string
Title string Title string
ProductSpec string ProductSpec string
SKU string SKU string
Quantity int Quantity int
ProductThumbRef string ProductThumbRef string
PurchaseStatus string OriginalUnitPriceMinor *int64
Revision int OriginalCurrency string
PurchaseStatus string
Revision int
} }
type FreightOrderDetail struct { type FreightOrderDetail struct {
@@ -160,14 +160,16 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
items := make([]FreightItemReview, 0, len(detail.Items)) items := make([]FreightItemReview, 0, len(detail.Items))
for _, item := range detail.Items { for _, item := range detail.Items {
view := FreightOrderItem{ view := FreightOrderItem{
ID: item.ID, ID: item.ID,
ExternalItemID: item.ExternalItemID, ExternalItemID: item.ExternalItemID,
Title: item.Title, Title: item.Title,
ProductSpec: item.ProductSpec, ProductSpec: item.ProductSpec,
SKU: item.SKU, SKU: item.SKU,
ProductThumbRef: stringValue(item.ProductThumbRef), ProductThumbRef: stringValue(item.ProductThumbRef),
PurchaseStatus: stringValue(item.PurchaseStatus), OriginalUnitPriceMinor: item.OriginalUnitPriceMinor,
Revision: item.Revision, OriginalCurrency: item.OriginalCurrency,
PurchaseStatus: stringValue(item.PurchaseStatus),
Revision: item.Revision,
} }
if item.Quantity != nil { if item.Quantity != nil {
view.Quantity = *item.Quantity view.Quantity = *item.Quantity
+25 -16
View File
@@ -728,6 +728,9 @@ func (service *FreightService) normalize(
!validBytes(sourceItem.ProductSpec, 1024) || !validBytes(sourceItem.ProductSpec, 1024) ||
!validBytes(sourceItem.SKU, 512) || !validBytes(sourceItem.SKU, 512) ||
!validOptional(sourceItem.ProductThumbRef, 512) || !validOptional(sourceItem.ProductThumbRef, 512) ||
sourceItem.OriginalCurrency != domain.FreightCurrencyTWD ||
(sourceItem.OriginalUnitPriceMinor != nil &&
*sourceItem.OriginalUnitPriceMinor < 0) ||
!validOptional(sourceItem.PurchaseStatus, 128) || !validOptional(sourceItem.PurchaseStatus, 128) ||
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) { (sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
return domain.FreightImportBatch{}, errors.New("invalid freight item fields") return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
@@ -737,26 +740,32 @@ func (service *FreightService) normalize(
return domain.FreightImportBatch{}, err return domain.FreightImportBatch{}, err
} }
item := domain.FreightImportItem{ item := domain.FreightImportItem{
ID: itemID, ID: itemID,
ExternalItemID: itemExternalID, ExternalItemID: itemExternalID,
Title: sourceItem.Title, Title: sourceItem.Title,
ProductSpec: sourceItem.ProductSpec, ProductSpec: sourceItem.ProductSpec,
SKU: sourceItem.SKU, SKU: sourceItem.SKU,
Quantity: sourceItem.Quantity, Quantity: sourceItem.Quantity,
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef), ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus), OriginalUnitPriceMinor: sourceItem.OriginalUnitPriceMinor,
OriginalCurrency: sourceItem.OriginalCurrency,
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
} }
item.CanonicalSHA256 = hashJSON(struct { item.CanonicalSHA256 = hashJSON(struct {
ExternalItemID string `json:"external_item_id"` ExternalItemID string `json:"external_item_id"`
Title string `json:"title"` Title string `json:"title"`
ProductSpec string `json:"product_spec"` ProductSpec string `json:"product_spec"`
SKU string `json:"sku"` SKU string `json:"sku"`
Quantity *int `json:"quantity"` Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"` ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"` 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.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) order.Items = append(order.Items, item)
} }
@@ -33,6 +33,19 @@ func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) {
second.Orders[0].CanonicalSHA256, 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( func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
@@ -466,6 +479,7 @@ func validFreightSource() domain.FreightSourceBatch {
thumb := "190" thumb := "190"
itemPurchaseStatus := "0" itemPurchaseStatus := "0"
quantity := 2 quantity := 2
originalUnitPriceMinor := int64(12950)
canceled := false canceled := false
return domain.FreightSourceBatch{ return domain.FreightSourceBatch{
SchemaVersion: 1, SchemaVersion: 1,
@@ -481,13 +495,15 @@ func validFreightSource() domain.FreightSourceBatch {
PurchaseStatus: &purchaseStatus, PurchaseStatus: &purchaseStatus,
IsCanceled: &canceled, IsCanceled: &canceled,
Items: []domain.FreightSourceItem{{ Items: []domain.FreightSourceItem{{
ExternalItemID: "88", ExternalItemID: "88",
Title: "商品", Title: "商品",
ProductSpec: "黑色,L", ProductSpec: "黑色,L",
SKU: "BLACK-L", SKU: "黑色,L",
Quantity: &quantity, Quantity: &quantity,
ProductThumbRef: &thumb, ProductThumbRef: &thumb,
PurchaseStatus: &itemPurchaseStatus, OriginalUnitPriceMinor: &originalUnitPriceMinor,
OriginalCurrency: domain.FreightCurrencyTWD,
PurchaseStatus: &itemPurchaseStatus,
}}, }},
}}, }},
} }
@@ -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;
+8 -1
View File
@@ -186,9 +186,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容
"external_item_id": "880011", "external_item_id": "880011",
"title": "商品标题", "title": "商品标题",
"product_spec": "灰色,2XL", "product_spec": "灰色,2XL",
"sku": "原始 SKU", "sku": "灰色,2XL",
"quantity": 2, "quantity": 2,
"product_thumb_ref": "190000000", "product_thumb_ref": "190000000",
"original_unit_price_minor": 12950,
"original_currency": "TWD",
"purchase_status": "0" "purchase_status": "0"
}] }]
}] }]
@@ -198,6 +200,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容
不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始 不得返回 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) ### Go ERP 会话(T-230、T-236)
`/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步 `/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步
+7 -4
View File
@@ -198,6 +198,10 @@
| `docs/tasks/T-233.md` | DONE | 修复预检错误映射并增加受控 ERP 诊断日志 | | `docs/tasks/T-233.md` | DONE | 修复预检错误映射并增加受控 ERP 诊断日志 |
| `docs/tasks/T-234.md` | DONE | 在受控诊断模式输出 OCR 验证码文本 | | `docs/tasks/T-234.md` | DONE | 在受控诊断模式输出 OCR 验证码文本 |
| `docs/tasks/T-235.md` | DONE | 修复顺运宝登录后用户会话校验协议 | | `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 页面 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -209,12 +213,11 @@
## 任务摘要 ## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 - 已完成: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 直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector
清理和受控本地凭证加载。 清理、受控本地凭证加载、同步单号导入、分钟时间兼容和货运商品元数据契约。
- 进行中:无。 - 进行中:无。
- 下一步:使用 `start-backend.bat --erp-debug` 重启 API 并重新执行受控单号 smoke;确认用户 - 下一步:实现商品 `productThumb` 图片的后端受控下载、本地保存和鉴权访问。
校验成功后继续观察 listTotal/list/detail 请求和同步结果。
## 当前可运行内容 ## 当前可运行内容
+12 -8
View File
@@ -4,7 +4,7 @@ title: 冻结货运商品规格、原始价格与图片引用契约
phase: 2 phase: 2
deps: deps:
- T-238 - T-238
status: TODO status: DONE
created: 2026-07-29 created: 2026-07-29
context_ref: 01be7e7 context_ref: 01be7e7
work_branch: null work_branch: null
@@ -55,13 +55,13 @@ ERP `sku/variationSku`,与本项目需要按颜色、尺码硬匹配的 `produ
## 验收要点 ## 验收要点
- [ ] `productSpec` 同时成为货运商品的规格和采购 SKU,缺失时不使用其他 ERP SKU 猜测。 - [x] `productSpec` 同时成为货运商品的规格和采购 SKU,缺失时不使用其他 ERP SKU 猜测。
- [ ] `productPrice` 的合法整数、小数和数字字符串精确转换为 TWD 最小单位。 - [x] `productPrice` 的合法整数、小数和数字字符串精确转换为 TWD 最小单位。
- [ ] 缺失价格为 `NULL`;负数、指数、超过两位小数和越界值拒绝为 ERP 协议错误。 - [x] 缺失价格为 `NULL`;负数、指数、超过两位小数和越界值拒绝为 ERP 协议错误。
- [ ] `productThumb` 仅接受可规范化的正整数引用,且不与货运单 ID 混用。 - [x] `productThumb` 仅接受可规范化的正整数引用,且不与货运单 ID 混用。
- [ ] 金额、币种和新 SKU 进入 canonical hash,并通过 SQLite 往返和 revision 测试。 - [x] 金额、币种和新 SKU 进入 canonical hash,并通过 SQLite 往返和 revision 测试。
- [ ] API/Web 适配层能读取新增字段;既有采购快照和任务不可变语义不回归。 - [x] API/Web 适配层能读取新增字段;既有采购快照和任务不可变语义不回归。
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 - [x] 标准 Go 测试、race、vet 和三个入口构建通过。
## 边界 ## 边界
@@ -73,3 +73,7 @@ ERP `sku/variationSku`,与本项目需要按颜色、尺码硬匹配的 `produ
- 2026-07-29:创建任务。确认图片 URL 的查询参数必须来自商品 `productThumb`,不能使用 - 2026-07-29:创建任务。确认图片 URL 的查询参数必须来自商品 `productThumb`,不能使用
货运单或商品明细 ID;金额使用定点最小单位,图片网络获取拆分到后续任务。 货运单或商品明细 ID;金额使用定点最小单位,图片网络获取拆分到后续任务。
- 2026-07-29:增加 migration 15 和货运领域/API/Web 字段;normalizer 以 `productSpec`
同时生成规格和采购 SKU,严格解析 TWD 原始单价及正整数图片引用。金额进入商品与货运单
canonical hash,SQLite 往返、revision、采购不可变快照和脱敏 API 回归通过。标准 Go
测试、race、vet 及三个入口构建均通过;真实 ERP JSON 保持未跟踪。