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

323 lines
8.3 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"])
}
sku := text(value["sku"])
if sku == "" {
sku = text(value["variationSku"])
}
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"]),
}, nil
}
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
}