Files
descrybe/apps/api/internal/woocommerce/orders_sync.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

355 lines
9.8 KiB
Go

package woocommerce
import (
"context"
"errors"
"encoding/json"
"fmt"
"math/big"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
defaultOrdersSyncLimit = 500
defaultReviewsSyncLimit = 500
defaultPullPageSize = 50
)
type OrdersSyncSummary struct {
Pages int `json:"pages"`
Fetched int `json:"fetched"`
Upserted int `json:"upserted"`
ItemsSaved int `json:"items_saved"`
Failed int `json:"failed"`
}
type ReviewsSyncSummary struct {
Pages int `json:"pages"`
Fetched int `json:"fetched"`
Upserted int `json:"upserted"`
Failed int `json:"failed"`
}
func (s *Service) EnqueueOrdersSync(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
if err := s.requireEnabledCreds(ctx, companyID); err != nil {
return nil, err
}
sc, err := s.loadStored(ctx, companyID)
if err != nil {
return nil, err
}
opt := parseSyncOptions(sc.syncOptions)
opt.PendingOrdersSync = true
if err := s.saveSyncOptions(ctx, companyID, opt); err != nil {
return nil, err
}
return map[string]any{"status": "accepted", "message": "woocommerce orders sync queued"}, nil
}
func (s *Service) EnqueueReviewsSync(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
if err := s.requireEnabledCreds(ctx, companyID); err != nil {
return nil, err
}
sc, err := s.loadStored(ctx, companyID)
if err != nil {
return nil, err
}
opt := parseSyncOptions(sc.syncOptions)
opt.PendingReviewsSync = true
if err := s.saveSyncOptions(ctx, companyID, opt); err != nil {
return nil, err
}
return map[string]any{"status": "accepted", "message": "woocommerce reviews sync queued"}, nil
}
func (s *Service) requireEnabledCreds(ctx context.Context, companyID uuid.UUID) error {
sc, err := s.loadStored(ctx, companyID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotConfigured
}
if err != nil {
return err
}
if !sc.enabled {
return ErrNotEnabled
}
key, err := DecryptSecret(s.Key, sc.keyEnc)
if err != nil {
return err
}
secret, err := DecryptSecret(s.Key, sc.secretEnc)
if err != nil {
return err
}
if sc.storeURL == "" || key == "" || secret == "" {
return ErrMissingCreds
}
return nil
}
func (s *Service) saveSyncOptions(ctx context.Context, companyID uuid.UUID, opt SyncOptions) error {
pruneStringIntMap(opt.ProductIDs, maxProductIDMap)
raw, err := json.Marshal(opt)
if err != nil {
return err
}
_, err = s.Pool.Exec(ctx, `
UPDATE woocommerce_configs SET sync_options = $2, updated_at = now()
WHERE company_id = $1`, companyID, raw)
return err
}
// ClaimNextPendingJob claims the next pending Woo sync (products, orders, or reviews).
func (s *Service) ClaimNextPendingJob(ctx context.Context) (uuid.UUID, string, error) {
var companyID uuid.UUID
var kind string
err := s.Pool.QueryRow(ctx, `
WITH candidate AS (
SELECT company_id,
CASE
WHEN COALESCE(sync_options->>'pending_sync', 'false') = 'true' THEN 'products'
WHEN COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true' THEN 'orders'
WHEN COALESCE(sync_options->>'pending_reviews_sync', 'false') = 'true' THEN 'reviews'
ELSE ''
END AS kind
FROM woocommerce_configs
WHERE is_enabled = true
AND (
COALESCE(sync_options->>'pending_sync', 'false') = 'true'
OR COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true'
OR COALESCE(sync_options->>'pending_reviews_sync', 'false') = 'true'
)
ORDER BY updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE woocommerce_configs c
SET sync_options = CASE candidate.kind
WHEN 'products' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_sync}', 'false'::jsonb, true)
WHEN 'orders' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_orders_sync}', 'false'::jsonb, true)
WHEN 'reviews' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_reviews_sync}', 'false'::jsonb, true)
ELSE c.sync_options
END,
updated_at = now()
FROM candidate
WHERE c.company_id = candidate.company_id AND candidate.kind <> ''
RETURNING c.company_id, candidate.kind`).Scan(&companyID, &kind)
return companyID, kind, err
}
// SyncOrders pulls Woo orders in pages and upserts company-scoped rows.
func (s *Service) SyncOrders(ctx context.Context, companyID uuid.UUID) (OrdersSyncSummary, error) {
client, _, opt, err := s.clientFor(ctx, companyID)
if err != nil {
return OrdersSyncSummary{}, err
}
limit := opt.OrdersSyncLimit
if limit <= 0 || limit > maxOrdersSyncLimit {
limit = defaultOrdersSyncLimit
}
pageSize := defaultPullPageSize
summary := OrdersSyncSummary{}
after := strings.TrimSpace(opt.OrdersModifiedAfter)
for page := 1; summary.Fetched < limit; page++ {
remaining := limit - summary.Fetched
perPage := pageSize
if remaining < perPage {
perPage = remaining
}
orders, _, err := client.ListOrdersPage(ctx, page, perPage, after)
if err != nil {
opt.PendingOrdersSync = false
opt.LastOrdersSyncStatus = "failed"
opt.LastOrdersSyncError = truncateErr(err)
_ = s.saveSyncOptions(ctx, companyID, opt)
return summary, err
}
if len(orders) == 0 {
break
}
summary.Pages++
summary.Fetched += len(orders)
for _, order := range orders {
nItems, err := s.upsertOrder(ctx, companyID, order)
if err != nil {
summary.Failed++
continue
}
summary.Upserted++
summary.ItemsSaved += nItems
}
if len(orders) < perPage {
break
}
}
opt.PendingOrdersSync = false
if summary.Failed > 0 && summary.Upserted == 0 {
opt.LastOrdersSyncStatus = "failed"
opt.LastOrdersSyncError = "all order upserts failed"
} else if summary.Failed > 0 {
opt.LastOrdersSyncStatus = "partial"
opt.LastOrdersSyncError = "some order upserts failed"
} else {
opt.LastOrdersSyncStatus = "success"
opt.LastOrdersSyncError = ""
}
now := timeNowUTC()
opt.LastOrdersSyncedAt = &now
if err := s.saveSyncOptions(ctx, companyID, opt); err != nil {
return summary, err
}
return summary, nil
}
func (s *Service) upsertOrder(ctx context.Context, companyID uuid.UUID, order Order) (int, error) {
payload := order.Raw
if len(payload) == 0 {
b, err := json.Marshal(order)
if err != nil {
return 0, err
}
payload = b
}
email := strings.TrimSpace(strings.ToLower(order.Billing.Email))
name := strings.TrimSpace(strings.TrimSpace(order.Billing.FirstName + " " + order.Billing.LastName))
orderedAt := parseWooTime(order.DateCreatedGMT, order.DateCreated)
total := parseDecimal(order.Total)
tx, err := s.Pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
var orderID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO woo_orders (
company_id, external_id, status, currency, total, customer_id, customer_email, customer_name,
ordered_at, payload, synced_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, NULLIF($6, 0), NULLIF($7, ''), NULLIF($8, ''),
$9, $10::jsonb, now(), now()
)
ON CONFLICT (company_id, external_id) DO UPDATE SET
status = EXCLUDED.status,
currency = EXCLUDED.currency,
total = EXCLUDED.total,
customer_id = EXCLUDED.customer_id,
customer_email = EXCLUDED.customer_email,
customer_name = EXCLUDED.customer_name,
ordered_at = EXCLUDED.ordered_at,
payload = EXCLUDED.payload,
synced_at = now(),
updated_at = now()
RETURNING id`,
companyID, order.ID, order.Status, order.Currency, total, order.CustomerID, email, name, orderedAt, payload,
).Scan(&orderID)
if err != nil {
return 0, err
}
_, err = tx.Exec(ctx, `DELETE FROM woo_order_items WHERE company_id = $1 AND order_id = $2`, companyID, orderID)
if err != nil {
return 0, err
}
saved := 0
for _, item := range order.LineItems {
itemPayload, _ := json.Marshal(item)
cats := extractItemCategories(item)
catsRaw, _ := json.Marshal(cats)
itemTotal := parseDecimal(item.Total)
_, err := tx.Exec(ctx, `
INSERT INTO woo_order_items (
company_id, order_id, external_id, product_id, variation_id, sku, name, quantity, total, categories, payload, updated_at
) VALUES (
$1, $2, $3, NULLIF($4, 0), NULLIF($5, 0), $6, $7, $8, $9, $10::jsonb, $11::jsonb, now()
)
ON CONFLICT (company_id, order_id, external_id) DO UPDATE SET
product_id = EXCLUDED.product_id,
variation_id = EXCLUDED.variation_id,
sku = EXCLUDED.sku,
name = EXCLUDED.name,
quantity = EXCLUDED.quantity,
total = EXCLUDED.total,
categories = EXCLUDED.categories,
payload = EXCLUDED.payload,
updated_at = now()`,
companyID, orderID, item.ID, item.ProductID, item.VariationID, strings.TrimSpace(item.SKU),
strings.TrimSpace(item.Name), item.Quantity, itemTotal, catsRaw, itemPayload,
)
if err != nil {
return 0, err
}
saved++
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return saved, nil
}
func extractItemCategories(item OrderLineItem) []any {
out := make([]any, 0)
if len(item.MetaData) == 0 {
return out
}
var meta []map[string]any
if json.Unmarshal(item.MetaData, &meta) != nil {
return out
}
for _, m := range meta {
key := strings.ToLower(fmt.Sprint(m["key"]))
if key != "categories" && key != "_categories" && key != "category" && !strings.Contains(key, "categor") {
continue
}
switch v := m["value"].(type) {
case string:
if strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
case []any:
out = append(out, v...)
case map[string]any:
if name, ok := v["name"].(string); ok && name != "" {
out = append(out, name)
}
if id, ok := v["id"]; ok {
out = append(out, id)
}
}
}
return out
}
func parseDecimal(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if _, _, err := big.ParseFloat(s, 10, 64, big.ToNearestEven); err != nil {
return nil
}
return &s
}
func truncateErr(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if len(msg) > 400 {
return msg[:400] + "…"
}
return msg
}
func timeNowUTC() time.Time { return time.Now().UTC() }