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

296 lines
7.1 KiB
Go
Raw Normal View History

2026-07-29 10:05:25 +08:00
package shunyunbao
import (
"context"
"encoding/json"
"reflect"
"strconv"
"cmroubao/backend-api/internal/domain"
)
const (
sourcePageSize = 20
sourceMaxMatches = 100
)
// QueryOrder implements the freight source port using the manager's single
// in-memory ERP session. It never exposes the ERP list or detail responses.
func (manager *SessionManager) QueryOrder(
ctx context.Context,
orderNumber string,
) (domain.FreightSourceBatch, error) {
query, err := OrderNumberQuery(orderNumber)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
records, err := manager.queryRecords(ctx, query)
if err != nil {
return domain.FreightSourceBatch{}, err
}
if len(records) == 0 {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceNotFound
}
return NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber},
records,
)
}
// QueryCreatedRange implements the bounded Asia/Shanghai date-window source
// port. The caller owns larger-window splitting and watermark semantics.
func (manager *SessionManager) QueryCreatedRange(
ctx context.Context,
createdFrom, createdTo string,
) (domain.FreightSourceBatch, error) {
query, err := CreatedRangeQuery(createdFrom, createdTo)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
records, err := manager.queryRecords(ctx, query)
if err != nil {
return domain.FreightSourceBatch{}, err
}
return NormalizeFreightResult(
RawQuery{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
},
records,
)
}
func (manager *SessionManager) queryRecords(
ctx context.Context,
query QueryCondition,
) ([]RawRecord, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return nil, domain.ErrFreightSourceNotConfigured
}
if !manager.authenticated {
return nil, domain.ErrFreightSourceSessionNeeded
}
if _, err := manager.validateLocked(ctx); err != nil {
return nil, err
}
stocks, err := manager.queryStocksLocked(ctx, query)
if err != nil {
return nil, err
}
if len(stocks) == 0 {
return []RawRecord{}, nil
}
details, err := manager.queryDetailsLocked(ctx, stocks)
if err != nil {
return nil, err
}
records := make([]RawRecord, 0, len(stocks))
for _, stock := range stocks {
stockID, err := sourceExternalID(stock["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
detail, exists := details[stockID]
if !exists {
return nil, domain.ErrFreightSourceProtocol
}
records = append(records, RawRecord{Stock: stock, Detail: detail})
}
return records, nil
}
func (manager *SessionManager) queryStocksLocked(
ctx context.Context,
query QueryCondition,
) ([]map[string]any, error) {
firstPayload, err := StockListPayload(query, 0, 1, sourcePageSize)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
firstBody, err := json.Marshal(firstPayload)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
totalData, err := manager.requestJSONLocked(
ctx,
"POST",
StockListTotalPath,
firstBody,
false,
false,
)
if err != nil {
return nil, err
}
total, err := sourceTotal(totalData)
if err != nil || total > sourceMaxMatches {
return nil, domain.ErrFreightSourceProtocol
}
if total == 0 {
return []map[string]any{}, nil
}
rows := make([]map[string]any, 0, total)
for start := 0; start < total; start += sourcePageSize {
payload, err := StockListPayload(
query,
start,
start/sourcePageSize+1,
sourcePageSize,
)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
body, err := json.Marshal(payload)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
data, err := manager.requestJSONLocked(
ctx,
"POST",
StockListPath,
body,
false,
false,
)
if err != nil {
return nil, err
}
page, err := sourceObjectList(data)
if err != nil || len(page) > sourcePageSize {
return nil, domain.ErrFreightSourceProtocol
}
rows = append(rows, page...)
}
if len(rows) != total {
return nil, domain.ErrFreightSourceProtocol
}
stocksByID := make(map[string]map[string]any, len(rows))
stocks := make([]map[string]any, 0, len(rows))
for _, stock := range rows {
stockID, err := sourceExternalID(stock["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
if existing, exists := stocksByID[stockID]; exists {
if !reflect.DeepEqual(existing, stock) {
return nil, domain.ErrFreightSourceProtocol
}
continue
}
stocksByID[stockID] = stock
stocks = append(stocks, stock)
}
return stocks, nil
}
func (manager *SessionManager) queryDetailsLocked(
ctx context.Context,
stocks []map[string]any,
) (map[string]map[string]any, error) {
ids := make([]string, 0, len(stocks))
for _, stock := range stocks {
stockID, err := sourceExternalID(stock["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
ids = append(ids, stockID)
}
detailsByID := make(map[string]map[string]any, len(ids))
requestedIDs := make(map[string]struct{}, len(ids))
for _, id := range ids {
requestedIDs[id] = struct{}{}
}
for start := 0; start < len(ids); start += maxDetailIDs {
end := start + maxDetailIDs
if end > len(ids) {
end = len(ids)
}
payload, err := StockDetailPayload(ids[start:end])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
body, err := json.Marshal(payload)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
data, err := manager.requestJSONLocked(
ctx,
"POST",
StockDetailPath+"?hist=0",
body,
false,
false,
)
if err != nil {
return nil, err
}
values, err := sourceObjectList(data)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
for _, detail := range values {
detailID, err := sourceExternalID(detail["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
if _, requested := requestedIDs[detailID]; !requested {
return nil, domain.ErrFreightSourceProtocol
}
if existing, exists := detailsByID[detailID]; exists &&
!reflect.DeepEqual(existing, detail) {
return nil, domain.ErrFreightSourceProtocol
}
detailsByID[detailID] = detail
}
}
if len(detailsByID) != len(ids) {
return nil, domain.ErrFreightSourceProtocol
}
return detailsByID, nil
}
func sourceTotal(value any) (int, error) {
var text string
switch typed := value.(type) {
case json.Number:
text = string(typed)
case string:
text = typed
default:
return 0, strconv.ErrSyntax
}
parsed, err := strconv.ParseInt(text, 10, 0)
if err != nil || parsed < 0 || parsed > int64(sourceMaxMatches) {
return 0, strconv.ErrSyntax
}
return int(parsed), nil
}
func sourceObjectList(value any) ([]map[string]any, error) {
data, ok := value.(map[string]any)
if !ok {
return nil, strconv.ErrSyntax
}
rawList, ok := data["list"].([]any)
if !ok {
return nil, strconv.ErrSyntax
}
result := make([]map[string]any, 0, len(rawList))
for _, value := range rawList {
row, ok := value.(map[string]any)
if !ok {
return nil, strconv.ErrSyntax
}
result = append(result, row)
}
return result, nil
}
func sourceExternalID(value any) (string, error) {
return externalID(value)
}