feat(t239): persist freight item metadata
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+8
-1
@@ -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*` 不再暴露。创建货运同步
|
||||
|
||||
@@ -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
@@ -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 保持未跟踪。
|
||||
|
||||
Reference in New Issue
Block a user