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"
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
}
@@ -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,
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) {
@@ -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")
}
@@ -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),
},
},
}}}
@@ -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{
@@ -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,
},
},
}},
@@ -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)
}
@@ -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")
+11 -9
View File
@@ -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 {
@@ -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
+25 -16
View File
@@ -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)
}
@@ -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,
}},
}},
}
@@ -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;