Files
cmroubao/backend-api/internal/platform/shunyunbao/normalizer.go
T

411 lines
10 KiB
Go

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
}