355 lines
9.8 KiB
Go
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() }
|
||
|
|
|