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.
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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() }
|
||||
|
||||
Reference in New Issue
Block a user