package usecase import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "sort" "strconv" "strings" "time" "unicode/utf8" "cmroubao/backend-api/internal/domain" ) const ( maxFreightOrdersPerSync = 100 maxFreightItemsPerOrder = 1000 maxFreightWindowDays = 7 freightWatermarkOverlap = 10 * time.Minute orderFreightSyncTimeout = 55 * time.Second freightCleanupTimeout = 5 * time.Second freightImageCacheBudget = 15 * time.Second ) type FreightService struct { repository FreightRepository source FreightSource clock Clock ids IDGenerator timeout time.Duration orderTimeout time.Duration cleanupTimeout time.Duration orderSyncGate chan struct{} imageCache FreightImageCache } type FreightServiceOption func(*FreightService) error func WithFreightImageCache(cache FreightImageCache) FreightServiceOption { return func(service *FreightService) error { if cache == nil { return errors.New("freight image cache is required") } service.imageCache = cache return nil } } type FreightSourcePreflight interface { EnsureAuthenticated(context.Context) error } type CreateFreightSyncCommand struct { CreatorSubject string ActorUserID string IdempotencyKey string OrderNumber string } type CreateFreightDateSyncCommand struct { CreatorSubject string ActorUserID string IdempotencyKey string CreatedFrom string CreatedTo string SyncToNow bool } type DeleteFreightOrderCommand struct { CreatorSubject string ActorUserID string OrderID string } type CreateFreightSyncResult struct { Run domain.FreightSyncRun Replayed bool } func NewFreightService( repository FreightRepository, source FreightSource, clock Clock, ids IDGenerator, timeout time.Duration, options ...FreightServiceOption, ) (*FreightService, error) { if repository == nil || source == nil || clock == nil || ids == nil || timeout <= 0 { return nil, errors.New("freight service dependencies are required") } service := &FreightService{ repository: repository, source: source, clock: clock, ids: ids, timeout: timeout, orderTimeout: orderFreightSyncTimeout, cleanupTimeout: freightCleanupTimeout, orderSyncGate: make(chan struct{}, 1), } for _, option := range options { if option == nil { return nil, errors.New("freight service option is required") } if err := option(service); err != nil { return nil, err } } return service, nil } func (service *FreightService) CreateOrderSync( ctx context.Context, command CreateFreightSyncCommand, ) (CreateFreightSyncResult, error) { command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) command.ActorUserID = strings.TrimSpace(command.ActorUserID) command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) command.OrderNumber = strings.TrimSpace(command.OrderNumber) fields := map[string]string{} if command.CreatorSubject == "" { fields["creator_subject"] = "is required" } if command.ActorUserID == "" { fields["actor_user_id"] = "is required" } if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 { fields["idempotency_key"] = "must contain 1 to 128 bytes" } if command.OrderNumber == "" || len([]byte(command.OrderNumber)) > 128 || hasControl(command.OrderNumber) { fields["order_number"] = "must contain 1 to 128 bytes without control characters" } if len(fields) > 0 { return CreateFreightSyncResult{}, invalidError( "FREIGHT_SYNC_INVALID", "freight sync request is invalid", fields, ) } if !service.acquireOrderSync() { return CreateFreightSyncResult{}, freightSyncBusyError() } defer service.releaseOrderSync() syncCtx, cancel := context.WithTimeout(ctx, service.orderTimeout) defer cancel() if err := service.ensureSource(syncCtx); err != nil { if syncErr := freightContextError(syncCtx); syncErr != nil { return CreateFreightSyncResult{}, syncErr } return CreateFreightSyncResult{}, err } runID, err := service.ids.NewID() if err != nil { return CreateFreightSyncResult{}, wrapRepositoryError(err) } now := service.clock.Now().UTC() queryHash := hashJSON(struct { Mode string `json:"mode"` OrderNumber string `json:"order_number"` }{domain.FreightSyncOrderNumber, command.OrderNumber}) requestHash := hashJSON(struct { OrderNumber string `json:"order_number"` }{command.OrderNumber}) run, created, err := service.repository.CreateFreightSync( syncCtx, domain.FreightSyncRun{ ID: runID, CreatorSubject: command.CreatorSubject, CreatedByUserID: command.ActorUserID, Mode: domain.FreightSyncOrderNumber, OrderNumber: command.OrderNumber, QuerySHA256: queryHash, Status: domain.FreightSyncPending, CreatedAt: now, }, command.IdempotencyKey, requestHash, ) if err != nil { return CreateFreightSyncResult{}, wrapRepositoryError(err) } if !created { if run.Status != domain.FreightSyncSucceeded && run.Status != domain.FreightSyncFailed { return CreateFreightSyncResult{}, freightSyncBusyError() } return CreateFreightSyncResult{Run: run, Replayed: true}, nil } completed, err := service.executeRun(syncCtx, run) if err != nil { return CreateFreightSyncResult{}, err } return CreateFreightSyncResult{Run: completed}, nil } func (service *FreightService) acquireOrderSync() bool { select { case service.orderSyncGate <- struct{}{}: return true default: return false } } func (service *FreightService) releaseOrderSync() { <-service.orderSyncGate } func (service *FreightService) CreateDateSync( ctx context.Context, command CreateFreightDateSyncCommand, ) (CreateFreightSyncResult, error) { command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) command.ActorUserID = strings.TrimSpace(command.ActorUserID) command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) command.CreatedFrom = strings.TrimSpace(command.CreatedFrom) command.CreatedTo = strings.TrimSpace(command.CreatedTo) fields := validateFreightActor( command.CreatorSubject, command.ActorUserID, command.IdempotencyKey, ) if command.SyncToNow && (command.CreatedFrom != "" || command.CreatedTo != "") { fields["sync_to_now"] = "cannot be combined with a manual range" } if !command.SyncToNow && (command.CreatedFrom == "" || command.CreatedTo == "") { fields["created_range"] = "created_from and created_to are required" } if len(fields) > 0 { return CreateFreightSyncResult{}, invalidError( "FREIGHT_SYNC_INVALID", "freight sync request is invalid", fields, ) } if err := service.ensureSource(ctx); err != nil { return CreateFreightSyncResult{}, err } now := service.clock.Now().UTC() location, err := time.LoadLocation("Asia/Shanghai") if err != nil { return CreateFreightSyncResult{}, wrapRepositoryError(err) } var startDate, endDate time.Time var watermarkThrough time.Time if command.SyncToNow { watermark, err := service.repository.GetFreightSyncWatermark( ctx, command.CreatorSubject, ) if err != nil { return CreateFreightSyncResult{}, wrapRepositoryError(err) } localNow := now.In(location) endDate = startOfLocalDay(localNow, location) startDate = endDate if watermark != nil { overlap := watermark.LastSuccessfulTo.Add( -freightWatermarkOverlap, ).In(location) startDate = startOfLocalDay(overlap, location) if startDate.After(endDate) { startDate = endDate } } watermarkThrough = now } else { startDate, err = parseFreightDate(command.CreatedFrom, location) if err != nil { fields["created_from"] = "must be YYYY-MM-DD" } endDate, err = parseFreightDate(command.CreatedTo, location) if err != nil { fields["created_to"] = "must be YYYY-MM-DD" } if len(fields) == 0 { if endDate.Before(startDate) { fields["created_to"] = "must not be before created_from" } else if daysInclusive(startDate, endDate) > maxFreightWindowDays { fields["created_range"] = "must not exceed 7 inclusive days" } if endDate.After(startOfLocalDay(now.In(location), location)) { fields["created_to"] = "must not be in the future" } } if len(fields) > 0 { return CreateFreightSyncResult{}, invalidError( "FREIGHT_SYNC_INVALID", "freight sync request is invalid", fields, ) } watermarkThrough = endDate.AddDate(0, 0, 1).Add(-time.Nanosecond).UTC() if watermarkThrough.After(now) { watermarkThrough = now } } createdFrom := startDate.Format(time.DateOnly) createdTo := endDate.Format(time.DateOnly) runID, err := service.ids.NewID() if err != nil { return CreateFreightSyncResult{}, wrapRepositoryError(err) } queryHash := hashJSON(struct { Mode string `json:"mode"` CreatedFrom string `json:"created_from"` CreatedTo string `json:"created_to"` }{ domain.FreightSyncCreatedRange, createdFrom, createdTo, }) requestHash := hashJSON(struct { SyncToNow bool `json:"sync_to_now"` CreatedFrom string `json:"created_from"` CreatedTo string `json:"created_to"` }{ command.SyncToNow, command.CreatedFrom, command.CreatedTo, }) run, created, err := service.repository.CreateFreightSync( ctx, domain.FreightSyncRun{ ID: runID, CreatorSubject: command.CreatorSubject, CreatedByUserID: command.ActorUserID, Mode: domain.FreightSyncCreatedRange, CreatedFrom: createdFrom, CreatedTo: createdTo, WatermarkThrough: &watermarkThrough, QuerySHA256: queryHash, Status: domain.FreightSyncPending, CreatedAt: now, }, command.IdempotencyKey, requestHash, ) if err != nil { return CreateFreightSyncResult{}, wrapRepositoryError(err) } if created { go service.execute(run) } return CreateFreightSyncResult{Run: run, Replayed: !created}, nil } func (service *FreightService) ensureSource(ctx context.Context) error { preflight, ok := service.source.(FreightSourcePreflight) if !ok { return nil } if err := preflight.EnsureAuthenticated(ctx); err != nil { code := freightSourceErrorCode(err) result := newError( ErrorKindUnavailable, code, freightPreflightMessage(code), err, ) result.Retryable = code == "OCR_SERVICE_INVALID" || code == "ERP_UNAVAILABLE" return result } return nil } func (service *FreightService) execute(run domain.FreightSyncRun) { ctx, cancel := context.WithTimeout(context.Background(), service.timeout) defer cancel() _, _ = service.executeRun(ctx, run) } func (service *FreightService) executeRun( ctx context.Context, run domain.FreightSyncRun, ) (domain.FreightSyncRun, error) { now := service.clock.Now().UTC() if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil { code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE") return domain.FreightSyncRun{}, service.finishFreightFailure( run.ID, code, wrapRepositoryError(err), ) } run.Status = domain.FreightSyncRunning run.StartedAt = &now var source domain.FreightSourceBatch var err error if run.Mode == domain.FreightSyncCreatedRange { source, err = service.queryCreatedRange(ctx, run) } else { source, err = service.source.QueryOrder(ctx, run.OrderNumber) } if err != nil { code := freightFailureCode(ctx, freightSourceErrorCode(err)) return domain.FreightSyncRun{}, service.finishFreightFailure( run.ID, code, freightExecutionError(code, err), ) } batch, err := service.normalize(source) if err != nil { code := freightFailureCode(ctx, "ERP_RESPONSE_INVALID") return domain.FreightSyncRun{}, service.finishFreightFailure( run.ID, code, freightExecutionError(code, domain.ErrFreightSourceProtocol), ) } finishedAt := service.clock.Now().UTC() if run.Mode == domain.FreightSyncCreatedRange && run.WatermarkThrough != nil { err = service.repository.CompleteFreightDateSync( ctx, run, batch, *run.WatermarkThrough, finishedAt, ) } else { err = service.repository.CompleteFreightSync( ctx, run, batch, finishedAt, ) } if err != nil { code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE") return domain.FreightSyncRun{}, service.finishFreightFailure( run.ID, code, wrapRepositoryError(err), ) } itemCount := 0 for _, order := range batch.Orders { itemCount += len(order.Items) } run.Status = domain.FreightSyncSucceeded run.OrderCount = len(batch.Orders) run.ItemCount = itemCount run.FinishedAt = &finishedAt if service.imageCache != nil { cacheCtx, cancel := context.WithTimeout( ctx, freightImageCacheBudget, ) _ = service.imageCache.CacheRun( cacheCtx, run.CreatorSubject, run.ID, ) cancel() } return run, nil } func (service *FreightService) finishFreightFailure( runID, code string, cause error, ) error { timeout := service.cleanupTimeout if timeout <= 0 { timeout = freightCleanupTimeout } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := service.repository.FailFreightSync( ctx, runID, code, service.clock.Now().UTC(), ); err != nil { return wrapRepositoryError(err) } return cause } func freightFailureCode(ctx context.Context, fallback string) string { switch ctx.Err() { case context.DeadlineExceeded: return "FREIGHT_SYNC_TIMEOUT" case context.Canceled: return "FREIGHT_SYNC_CANCELED" default: return fallback } } func freightContextError(ctx context.Context) error { switch ctx.Err() { case context.DeadlineExceeded: return freightExecutionError( "FREIGHT_SYNC_TIMEOUT", context.DeadlineExceeded, ) case context.Canceled: return freightExecutionError( "FREIGHT_SYNC_CANCELED", context.Canceled, ) default: return nil } } func freightSyncBusyError() error { result := newError( ErrorKindConflict, "FREIGHT_SYNC_BUSY", "another freight order sync is already running", nil, ) result.Retryable = true return result } func freightExecutionError(code string, cause error) error { var kind ErrorKind var message string switch code { case "FREIGHT_SYNC_TIMEOUT": kind = ErrorKindUnavailable message = "freight order sync exceeded its time limit" case "FREIGHT_SYNC_CANCELED": kind = ErrorKindUnavailable message = "freight order sync was canceled" case "ERP_FREIGHT_NOT_FOUND": kind = ErrorKindNotFound message = "ERP freight order was not found" default: kind = ErrorKindUnavailable message = freightPreflightMessage(code) } result := newError(kind, code, message, cause) result.Retryable = code == "FREIGHT_SYNC_TIMEOUT" || code == "FREIGHT_SYNC_CANCELED" || code == "ERP_UNAVAILABLE" || code == "OCR_SERVICE_INVALID" return result } func (service *FreightService) queryCreatedRange( ctx context.Context, run domain.FreightSyncRun, ) (domain.FreightSourceBatch, error) { location, err := time.LoadLocation("Asia/Shanghai") if err != nil { return domain.FreightSourceBatch{}, err } start, err := parseFreightDate(run.CreatedFrom, location) if err != nil { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } end, err := parseFreightDate(run.CreatedTo, location) if err != nil || end.Before(start) { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } orders := make([]domain.FreightSourceOrder, 0) seen := make(map[string]domain.FreightSourceOrder) for windowStart := start; !windowStart.After(end); { windowEnd := windowStart.AddDate(0, 0, maxFreightWindowDays-1) if windowEnd.After(end) { windowEnd = end } fromValue := windowStart.Format(time.DateOnly) toValue := windowEnd.Format(time.DateOnly) batch, err := service.source.QueryCreatedRange( ctx, fromValue, toValue, ) if err != nil { return domain.FreightSourceBatch{}, err } if batch.SchemaVersion != 1 || batch.Query.Mode != domain.FreightSyncCreatedRange || batch.Query.CreatedFrom == nil || batch.Query.CreatedTo == nil || *batch.Query.CreatedFrom != fromValue || *batch.Query.CreatedTo != toValue { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } for _, order := range batch.Orders { existing, exists := seen[order.ExternalStockID] if exists { if hashJSON(existing) != hashJSON(order) { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } continue } seen[order.ExternalStockID] = order orders = append(orders, order) if len(orders) > maxFreightOrdersPerSync { return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol } } windowStart = windowEnd.AddDate(0, 0, 1) } createdFrom := run.CreatedFrom createdTo := run.CreatedTo return domain.FreightSourceBatch{ SchemaVersion: 1, Query: domain.FreightSourceQuery{ Mode: domain.FreightSyncCreatedRange, CreatedFrom: &createdFrom, CreatedTo: &createdTo, }, Orders: orders, }, nil } func (service *FreightService) RecoverInterrupted( ctx context.Context, ) (int64, error) { count, err := service.repository.RecoverFreightSyncs( ctx, service.clock.Now().UTC(), ) if err != nil { return 0, wrapRepositoryError(err) } return count, nil } func (service *FreightService) GetSync( ctx context.Context, creatorSubject, syncID string, ) (domain.FreightSyncRun, error) { run, err := service.repository.GetFreightSync( ctx, strings.TrimSpace(creatorSubject), strings.TrimSpace(syncID), ) if err != nil { return domain.FreightSyncRun{}, wrapRepositoryError(err) } return run, nil } func (service *FreightService) GetWatermark( ctx context.Context, creatorSubject string, ) (*domain.FreightSyncWatermark, error) { watermark, err := service.repository.GetFreightSyncWatermark( ctx, strings.TrimSpace(creatorSubject), ) if err != nil { return nil, wrapRepositoryError(err) } return watermark, nil } func (service *FreightService) ListOrders( ctx context.Context, creatorSubject string, limit int, ) ([]domain.FreightOrder, error) { if limit == 0 { limit = 50 } if limit < 1 || limit > 100 { return nil, invalidError( "FREIGHT_LIST_INVALID", "freight list filter is invalid", map[string]string{"limit": "must be between 1 and 100"}, ) } orders, err := service.repository.ListFreightOrders( ctx, strings.TrimSpace(creatorSubject), limit, ) if err != nil { return nil, wrapRepositoryError(err) } return orders, nil } func (service *FreightService) GetOrder( ctx context.Context, creatorSubject, orderID string, ) (domain.FreightOrderDetail, error) { detail, err := service.repository.GetFreightOrder( ctx, strings.TrimSpace(creatorSubject), strings.TrimSpace(orderID), ) if err != nil { return domain.FreightOrderDetail{}, wrapRepositoryError(err) } return detail, nil } func (service *FreightService) DeleteOrder( ctx context.Context, command DeleteFreightOrderCommand, ) error { command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) command.ActorUserID = strings.TrimSpace(command.ActorUserID) command.OrderID = strings.TrimSpace(command.OrderID) fields := map[string]string{} if command.CreatorSubject == "" { fields["creator_subject"] = "is required" } if !isUUID(command.ActorUserID) { fields["actor_user_id"] = "must be a UUID" } if !isUUID(command.OrderID) { fields["order_id"] = "must be a UUID" } if len(fields) > 0 { return invalidError( "FREIGHT_DELETE_INVALID", "freight order deletion is invalid", fields, ) } storageKeys, err := service.repository.DeleteFreightOrder( ctx, command.CreatorSubject, command.OrderID, ) if err != nil { return wrapRepositoryError(err) } if service.imageCache != nil { service.imageCache.DeleteStoredImages(storageKeys) } return nil } func (service *FreightService) normalize( source domain.FreightSourceBatch, ) (domain.FreightImportBatch, error) { if source.SchemaVersion != 1 || (source.Query.Mode != domain.FreightSyncOrderNumber && source.Query.Mode != domain.FreightSyncCreatedRange) || len(source.Orders) > maxFreightOrdersPerSync { return domain.FreightImportBatch{}, errors.New("invalid source envelope") } seenOrders := map[string]struct{}{} result := domain.FreightImportBatch{ Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)), } for _, sourceOrder := range source.Orders { externalID, ok := validExternalID(sourceOrder.ExternalStockID) if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder { return domain.FreightImportBatch{}, errors.New("invalid freight order") } if _, exists := seenOrders[externalID]; exists { return domain.FreightImportBatch{}, errors.New("duplicate freight order") } seenOrders[externalID] = struct{}{} if !validBytes(sourceOrder.SourceCode, 256) || !validOptional(sourceOrder.PlatformOrderNo, 256) || !validOptional(sourceOrder.ShopName, 512) || !validOptional(sourceOrder.OrderStatus, 128) || !validOptional(sourceOrder.PurchaseStatus, 128) { return domain.FreightImportBatch{}, errors.New("invalid freight fields") } sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt) if err != nil { return domain.FreightImportBatch{}, err } orderID, err := service.ids.NewID() if err != nil { return domain.FreightImportBatch{}, err } order := domain.FreightImportOrder{ ID: orderID, ExternalStockID: externalID, SourceCode: sourceOrder.SourceCode, PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo), ShopName: cleanOptional(sourceOrder.ShopName), SourceCreatedAt: sourceCreatedAt, OrderStatus: cleanOptional(sourceOrder.OrderStatus), PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus), IsCanceled: sourceOrder.IsCanceled, Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)), } seenItems := map[string]struct{}{} for _, sourceItem := range sourceOrder.Items { itemExternalID, ok := validExternalID(sourceItem.ExternalItemID) if !ok { return domain.FreightImportBatch{}, errors.New("invalid freight item") } if _, exists := seenItems[itemExternalID]; exists { return domain.FreightImportBatch{}, errors.New("duplicate freight item") } seenItems[itemExternalID] = struct{}{} if !validBytes(sourceItem.Title, 2048) || !validBytes(sourceItem.ProductSpec, 1024) || !validBytes(sourceItem.SKU, 512) || !validOptional(sourceItem.ProductThumbRef, 512) || sourceItem.OriginalCurrency != domain.FreightCurrencyTWD || (sourceItem.OriginalUnitPriceMinor != nil && *sourceItem.OriginalUnitPriceMinor < 0) || !validOptional(sourceItem.PurchaseStatus, 128) || (sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) { return domain.FreightImportBatch{}, errors.New("invalid freight item fields") } itemID, err := service.ids.NewID() if err != nil { return domain.FreightImportBatch{}, err } item := domain.FreightImportItem{ ID: itemID, ExternalItemID: itemExternalID, Title: sourceItem.Title, ProductSpec: sourceItem.ProductSpec, SKU: sourceItem.SKU, Quantity: sourceItem.Quantity, ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef), OriginalUnitPriceMinor: sourceItem.OriginalUnitPriceMinor, OriginalCurrency: sourceItem.OriginalCurrency, PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus), } item.CanonicalSHA256 = hashJSON(struct { ExternalItemID string `json:"external_item_id"` Title string `json:"title"` ProductSpec string `json:"product_spec"` SKU string `json:"sku"` Quantity *int `json:"quantity"` ProductThumbRef *string `json:"product_thumb_ref"` OriginalUnitPriceMinor *int64 `json:"original_unit_price_minor"` OriginalCurrency string `json:"original_currency"` PurchaseStatus *string `json:"purchase_status"` }{ item.ExternalItemID, item.Title, item.ProductSpec, item.SKU, item.Quantity, item.ProductThumbRef, item.OriginalUnitPriceMinor, item.OriginalCurrency, item.PurchaseStatus, }) order.Items = append(order.Items, item) } sort.Slice(order.Items, func(i, j int) bool { left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64) right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64) return left < right }) type canonicalItem struct { ExternalItemID string `json:"external_item_id"` CanonicalSHA256 string `json:"canonical_sha256"` } canonicalItems := make([]canonicalItem, 0, len(order.Items)) for _, item := range order.Items { canonicalItems = append(canonicalItems, canonicalItem{ ExternalItemID: item.ExternalItemID, CanonicalSHA256: item.CanonicalSHA256, }) } order.CanonicalSHA256 = hashJSON(struct { ExternalStockID string `json:"external_stock_id"` SourceCode string `json:"source_code"` PlatformOrderNo *string `json:"platform_order_no"` ShopName *string `json:"shop_name"` SourceCreatedAt *time.Time `json:"source_created_at"` OrderStatus *string `json:"order_status"` PurchaseStatus *string `json:"purchase_status"` IsCanceled *bool `json:"is_canceled"` Items []canonicalItem `json:"items"` }{ order.ExternalStockID, order.SourceCode, order.PlatformOrderNo, order.ShopName, order.SourceCreatedAt, order.OrderStatus, order.PurchaseStatus, order.IsCanceled, canonicalItems, }) result.Orders = append(result.Orders, order) } sort.Slice(result.Orders, func(i, j int) bool { left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64) right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64) return left < right }) return result, nil } func validateFreightActor( creatorSubject, actorUserID, idempotencyKey string, ) map[string]string { fields := map[string]string{} if creatorSubject == "" { fields["creator_subject"] = "is required" } if actorUserID == "" { fields["actor_user_id"] = "is required" } if idempotencyKey == "" || len([]byte(idempotencyKey)) > 128 { fields["idempotency_key"] = "must contain 1 to 128 bytes" } return fields } func parseFreightDate(value string, location *time.Location) (time.Time, error) { parsed, err := time.ParseInLocation(time.DateOnly, value, location) if err != nil || parsed.Format(time.DateOnly) != value { return time.Time{}, errors.New("invalid freight date") } return parsed, nil } func startOfLocalDay(value time.Time, location *time.Location) time.Time { return time.Date( value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location, ) } func daysInclusive(start, end time.Time) int { return int(end.Sub(start)/(24*time.Hour)) + 1 } func freightSourceErrorCode(err error) string { switch { case errors.Is(err, domain.ErrFreightSourceNotConfigured): return "ERP_NOT_CONFIGURED" case errors.Is(err, domain.ErrFreightSourceSessionNeeded): return "ERP_SESSION_REQUIRED" case errors.Is(err, domain.ErrFreightSourceNotFound): return "ERP_FREIGHT_NOT_FOUND" case errors.Is(err, domain.ErrFreightSourceProtocol): return "ERP_RESPONSE_INVALID" case errors.Is(err, domain.ErrFreightSourceOCRInvalid): return "OCR_SERVICE_INVALID" case errors.Is(err, domain.ErrFreightSourceLoginRejected): return "ERP_LOGIN_REJECTED" default: return "ERP_UNAVAILABLE" } } func freightPreflightMessage(code string) string { switch code { case "ERP_NOT_CONFIGURED": return "ERP credentials are not configured" case "ERP_LOGIN_REJECTED": return "ERP login was rejected" case "ERP_RESPONSE_INVALID": return "ERP response is invalid" case "OCR_SERVICE_INVALID": return "OCR service is invalid" default: return "ERP is temporarily unavailable" } } func validExternalID(value string) (string, bool) { value = strings.TrimSpace(value) number, err := strconv.ParseUint(value, 10, 64) return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value } func validBytes(value string, maximum int) bool { return utf8.ValidString(value) && len([]byte(value)) <= maximum && !hasControl(value) } func validOptional(value *string, maximum int) bool { return value == nil || validBytes(strings.TrimSpace(*value), maximum) } func cleanOptional(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } func hasControl(value string) bool { for _, character := range value { if character < 0x20 || character == 0x7f { return true } } return false } func parseERPTime(value *string) (*time.Time, error) { value = cleanOptional(value) if value == nil { return nil, nil } location, err := time.LoadLocation("Asia/Shanghai") if err != nil { return nil, err } layouts := []string{ time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02T15:04", } for _, layout := range layouts { var parsed time.Time if layout == time.RFC3339Nano { parsed, err = time.Parse(layout, *value) } else { parsed, err = time.ParseInLocation(layout, *value, location) } if err == nil { result := parsed.UTC() return &result, nil } } return nil, errors.New("invalid ERP source time") } func hashJSON(value any) string { encoded, _ := json.Marshal(value) sum := sha256.Sum256(encoded) return hex.EncodeToString(sum[:]) }