feat(t225): freeze Go ERP protocol contract
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user