package shunyunbao import ( "encoding/json" "fmt" "math" "reflect" "sort" "strconv" "strings" "time" "cmroubao/backend-api/internal/domain" ) type RawQuery struct { Mode string `json:"mode"` CreatedFrom string `json:"createdFrom"` CreatedTo string `json:"createdTo"` } type RawRecord struct { Stock map[string]any `json:"stock"` Detail map[string]any `json:"detail"` } type RawFreightResult struct { Query RawQuery `json:"query"` Records []RawRecord `json:"records"` } // NormalizeFreightResult reduces an ERP response to the domain allowlist. // Values not explicitly copied below, including recipient PII and session data, // cannot reach its result. func NormalizeFreightResult( query RawQuery, records []RawRecord, ) (domain.FreightSourceBatch, error) { normalizedQuery, err := normalizeQuery(query) if err != nil { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } ordersByID := make(map[string]domain.FreightSourceOrder, len(records)) for _, record := range records { order, err := normalizeOrder(record) if err != nil { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } if existing, exists := ordersByID[order.ExternalStockID]; exists && !reflect.DeepEqual(existing, order) { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } ordersByID[order.ExternalStockID] = order } orders := make([]domain.FreightSourceOrder, 0, len(ordersByID)) for _, order := range ordersByID { orders = append(orders, order) } sort.Slice(orders, func(left, right int) bool { return externalIDLess(orders[left].ExternalStockID, orders[right].ExternalStockID) }) return domain.FreightSourceBatch{ SchemaVersion: 1, Query: normalizedQuery, Orders: orders, }, nil } func normalizeQuery(value RawQuery) (domain.FreightSourceQuery, error) { switch value.Mode { case "", domain.FreightSyncOrderNumber: return domain.FreightSourceQuery{Mode: domain.FreightSyncOrderNumber}, nil case domain.FreightSyncCreatedRange: if !validDate(value.CreatedFrom) || !validDate(value.CreatedTo) { return domain.FreightSourceQuery{}, errInvalidProtocolInput } return domain.FreightSourceQuery{ Mode: domain.FreightSyncCreatedRange, CreatedFrom: stringPointer(value.CreatedFrom), CreatedTo: stringPointer(value.CreatedTo), }, nil default: return domain.FreightSourceQuery{}, errInvalidProtocolInput } } func normalizeOrder(record RawRecord) (domain.FreightSourceOrder, error) { if record.Stock == nil || record.Detail == nil { return domain.FreightSourceOrder{}, errInvalidProtocolInput } stockID, err := externalID(record.Stock["id"]) if err != nil { return domain.FreightSourceOrder{}, err } detailID, err := externalID(record.Detail["id"]) if err != nil || detailID != stockID { return domain.FreightSourceOrder{}, errInvalidProtocolInput } rawItems, ok := record.Detail["details"].([]any) if !ok { return domain.FreightSourceOrder{}, errInvalidProtocolInput } itemsByID := make(map[string]domain.FreightSourceItem, len(rawItems)) for _, rawItem := range rawItems { itemMap, ok := rawItem.(map[string]any) if !ok { return domain.FreightSourceOrder{}, errInvalidProtocolInput } item, err := normalizeItem(itemMap) if err != nil { return domain.FreightSourceOrder{}, err } if existing, exists := itemsByID[item.ExternalItemID]; exists && !reflect.DeepEqual(existing, item) { return domain.FreightSourceOrder{}, errInvalidProtocolInput } itemsByID[item.ExternalItemID] = item } items := make([]domain.FreightSourceItem, 0, len(itemsByID)) for _, item := range itemsByID { items = append(items, item) } sort.Slice(items, func(left, right int) bool { return externalIDLess(items[left].ExternalItemID, items[right].ExternalItemID) }) shopName := optionalText(record.Detail["shopName"]) if shopName == nil { shopName = optionalText(record.Stock["shopName"]) } createdAt := optionalText(record.Detail["created"]) if createdAt == nil { createdAt = optionalText(record.Stock["created"]) } orderStatus := optionalScalar(record.Stock["orderStatus"]) if orderStatus == nil { orderStatus = optionalScalar(record.Detail["status"]) } return domain.FreightSourceOrder{ ExternalStockID: stockID, SourceCode: text(record.Stock["code"]), PlatformOrderNo: optionalText(record.Stock["orderCode"]), ShopName: shopName, SourceCreatedAt: createdAt, OrderStatus: orderStatus, PurchaseStatus: optionalScalar(record.Stock["purchaseStatus"]), IsCanceled: optionalBoolean(record.Stock["isCancel"]), Items: items, }, nil } func normalizeItem(value map[string]any) (domain.FreightSourceItem, error) { externalItemID, err := externalID(value["id"]) if err != nil { return domain.FreightSourceItem{}, err } title := text(value["productTitle"]) if title == "" { title = text(value["detailProductName"]) } 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: 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) { case string: parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64) if err != nil { return "", errInvalidProtocolInput } normalized = parsed case json.Number: parsed, err := strconv.ParseUint(string(typed), 10, 64) if err != nil { return "", errInvalidProtocolInput } normalized = parsed case float64: if typed != math.Trunc(typed) || typed <= 0 || typed > 1<<53 { return "", errInvalidProtocolInput } normalized = uint64(typed) case int: if typed < 1 { return "", errInvalidProtocolInput } normalized = uint64(typed) case int64: if typed < 1 { return "", errInvalidProtocolInput } normalized = uint64(typed) case uint64: normalized = typed default: return "", errInvalidProtocolInput } if normalized == 0 { return "", errInvalidProtocolInput } return strconv.FormatUint(normalized, 10), nil } func positiveInt(value any) *int { var normalized int64 switch typed := value.(type) { case json.Number: parsed, err := strconv.ParseInt(string(typed), 10, 0) if err != nil { return nil } normalized = parsed case float64: if typed != math.Trunc(typed) || typed > float64(math.MaxInt) || typed < 1 { return nil } normalized = int64(typed) case int: normalized = int64(typed) case int64: normalized = typed case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 0) if err != nil { return nil } normalized = parsed default: return nil } if normalized < 1 || normalized > int64(math.MaxInt) { return nil } result := int(normalized) return &result } func optionalBoolean(value any) *bool { switch typed := value.(type) { case bool: return &typed case string: if typed == "0" { result := false return &result } if typed == "1" { result := true return &result } case json.Number: return optionalBoolean(string(typed)) case float64: if typed == 0 || typed == 1 { result := typed == 1 return &result } case int: if typed == 0 || typed == 1 { result := typed == 1 return &result } } return nil } func text(value any) string { typedValue, ok := value.(string) if !ok { return "" } return strings.TrimSpace(typedValue) } func optionalText(value any) *string { normalized := text(value) if normalized == "" { return nil } return &normalized } func optionalScalar(value any) *string { if value == nil { return nil } switch value.(type) { case map[string]any, []any, map[any]any, []string: return nil } normalized := strings.TrimSpace(fmt.Sprint(value)) if normalized == "" { return nil } return &normalized } func validDate(value string) bool { parsed, err := time.Parse(time.DateOnly, value) return err == nil && parsed.Format(time.DateOnly) == value } func externalIDLess(left, right string) bool { leftValue, _ := strconv.ParseUint(left, 10, 64) rightValue, _ := strconv.ParseUint(right, 10, 64) return leftValue < rightValue } func stringPointer(value string) *string { return &value }