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
+7
View File
@@ -6,6 +6,7 @@ const (
FreightSourceShunyunbao = "SHUNYUNBAO"
FreightSyncOrderNumber = "ORDER_NUMBER"
FreightSyncCreatedRange = "CREATED_RANGE"
FreightCurrencyTWD = "TWD"
)
type FreightSyncStatus string
@@ -66,6 +67,8 @@ type FreightOrderItem struct {
SKU string
Quantity *int
ProductThumbRef *string
OriginalUnitPriceMinor *int64
OriginalCurrency string
PurchaseStatus *string
CanonicalSHA256 string
Revision int
@@ -120,6 +123,8 @@ type FreightSourceItem struct {
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"`
}
@@ -149,6 +154,8 @@ type FreightImportItem struct {
SKU string
Quantity *int
ProductThumbRef *string
OriginalUnitPriceMinor *int64
OriginalCurrency string
PurchaseStatus *string
CanonicalSHA256 string
}
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 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)
}
@@ -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,
})
}
@@ -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,
ProductSpec: productSpec,
SKU: productSpec,
Quantity: positiveInt(value["productQty"]),
ProductThumbRef: optionalScalar(value["productThumb"]),
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) {
@@ -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},
@@ -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)
@@ -32,6 +32,7 @@
"sku": "BLACK-L",
"productQty": 2,
"productThumb": 190,
"productPrice": "129.50",
"purchaseStatus": 0,
"receiverTel": "item-phone-not-allowed"
}
@@ -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)
}
}
@@ -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 {
@@ -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")
}
@@ -338,8 +358,9 @@ func freightBatch(index int, firstHash, secondHash string) domain.FreightImportB
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
SKU: "黑色,L",
Quantity: &quantityOne,
OriginalCurrency: domain.FreightCurrencyTWD,
CanonicalSHA256: repeatHex(firstHash),
},
{
@@ -347,8 +368,9 @@ func freightBatch(index int, firstHash, secondHash string) domain.FreightImportB
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "WHITE-M",
SKU: "白色,M",
Quantity: &quantityTwo,
OriginalCurrency: domain.FreightCurrencyTWD,
CanonicalSHA256: repeatHex(secondHash),
},
},
@@ -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
@@ -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)
}
@@ -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{
@@ -1278,15 +1285,19 @@ func (staticFreightSource) QueryOrder(
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
SKU: "黑色,L",
Quantity: &quantityOne,
OriginalUnitPriceMinor: &priceOne,
OriginalCurrency: domain.FreightCurrencyTWD,
},
{
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "WHITE-M",
SKU: "白色,M",
Quantity: &quantityTwo,
OriginalUnitPriceMinor: &priceTwo,
OriginalCurrency: domain.FreightCurrencyTWD,
},
},
}},
@@ -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)
}
@@ -213,6 +213,8 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
"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,
@@ -157,6 +157,8 @@ type FreightOrderItem struct {
SKU string
Quantity int
ProductThumbRef string
OriginalUnitPriceMinor *int64
OriginalCurrency string
PurchaseStatus string
Revision int
}
@@ -166,6 +166,8 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
ProductSpec: item.ProductSpec,
SKU: item.SKU,
ProductThumbRef: stringValue(item.ProductThumbRef),
OriginalUnitPriceMinor: item.OriginalUnitPriceMinor,
OriginalCurrency: item.OriginalCurrency,
PurchaseStatus: stringValue(item.PurchaseStatus),
Revision: item.Revision,
}
@@ -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")
@@ -744,6 +747,8 @@ func (service *FreightService) normalize(
SKU: sourceItem.SKU,
Quantity: sourceItem.Quantity,
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
OriginalUnitPriceMinor: sourceItem.OriginalUnitPriceMinor,
OriginalCurrency: sourceItem.OriginalCurrency,
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
}
item.CanonicalSHA256 = hashJSON(struct {
@@ -753,10 +758,14 @@ func (service *FreightService) normalize(
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)
}
@@ -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,
@@ -484,9 +498,11 @@ func validFreightSource() domain.FreightSourceBatch {
ExternalItemID: "88",
Title: "商品",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
SKU: "黑色,L",
Quantity: &quantity,
ProductThumbRef: &thumb,
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",
"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*` 不再暴露。创建货运同步
+7 -4
View File
@@ -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` 图片的后端受控下载、本地保存和鉴权访问。
## 当前可运行内容
+12 -8
View File
@@ -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 保持未跟踪。