323 lines
8.3 KiB
Go
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
|
|
}
|