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,295 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOrdersSyncLimit = 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"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type OrderListFilter struct {
|
||||
Status string
|
||||
Email string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type OrderRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExternalID int64 `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
Currency string `json:"currency"`
|
||||
Total *string `json:"total"`
|
||||
CustomerEmail *string `json:"customer_email"`
|
||||
CustomerName *string `json:"customer_name"`
|
||||
OrderedAt *time.Time `json:"ordered_at"`
|
||||
}
|
||||
|
||||
// SyncOrders pulls Shopify 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
|
||||
}
|
||||
summary := OrdersSyncSummary{DryRun: client.DryRun}
|
||||
pageInfo := ""
|
||||
fetched := 0
|
||||
for fetched < limit {
|
||||
summary.Pages++
|
||||
pageSize := defaultPullPageSize
|
||||
if remaining := limit - fetched; remaining < pageSize {
|
||||
pageSize = remaining
|
||||
}
|
||||
orders, next, err := client.ListOrdersPage(ctx, pageInfo, pageSize)
|
||||
if err != nil {
|
||||
opt.LastOrdersSyncStatus = "failed"
|
||||
opt.LastOrdersSyncError = err.Error()
|
||||
opt.PendingOrdersSync = false
|
||||
_ = s.saveSyncOptions(ctx, companyID, opt)
|
||||
return summary, err
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
break
|
||||
}
|
||||
for _, order := range orders {
|
||||
fetched++
|
||||
summary.Fetched++
|
||||
items, err := s.upsertOrder(ctx, companyID, order)
|
||||
if err != nil {
|
||||
summary.Failed++
|
||||
continue
|
||||
}
|
||||
summary.Upserted++
|
||||
summary.ItemsSaved += items
|
||||
}
|
||||
if next == "" || len(orders) < pageSize {
|
||||
break
|
||||
}
|
||||
pageInfo = next
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
opt.LastOrdersSyncedAt = &now
|
||||
opt.PendingOrdersSync = false
|
||||
if summary.Failed == 0 {
|
||||
opt.LastOrdersSyncStatus = "success"
|
||||
opt.LastOrdersSyncError = ""
|
||||
} else if summary.Upserted == 0 {
|
||||
opt.LastOrdersSyncStatus = "failed"
|
||||
opt.LastOrdersSyncError = "all order upserts failed"
|
||||
} else {
|
||||
opt.LastOrdersSyncStatus = "partial"
|
||||
opt.LastOrdersSyncError = "some order upserts failed"
|
||||
}
|
||||
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) {
|
||||
email := strings.TrimSpace(order.Email)
|
||||
name := ""
|
||||
var customerID *int64
|
||||
if order.Customer != nil {
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(order.Customer.Email)
|
||||
}
|
||||
name = strings.TrimSpace(order.Customer.FirstName + " " + order.Customer.LastName)
|
||||
if order.Customer.ID > 0 {
|
||||
id := order.Customer.ID
|
||||
customerID = &id
|
||||
}
|
||||
}
|
||||
status := firstNonEmpty(order.FinancialStatus, order.FulfillmentStatus, "unknown")
|
||||
orderedAt := parseShopifyTime(order.CreatedAt)
|
||||
payload := order.Raw
|
||||
if len(payload) == 0 {
|
||||
payload, _ = json.Marshal(order)
|
||||
}
|
||||
var total any
|
||||
if order.TotalPrice != "" {
|
||||
if r, ok := new(big.Rat).SetString(order.TotalPrice); ok {
|
||||
total = r.FloatString(2)
|
||||
} else {
|
||||
total = order.TotalPrice
|
||||
}
|
||||
}
|
||||
|
||||
var orderID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO shopify_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,$6,NULLIF($7,''),NULLIF($8,''),$9,$10,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, status, order.Currency, total, customerID, email, name, orderedAt, payload,
|
||||
).Scan(&orderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
itemsSaved := 0
|
||||
for _, li := range order.LineItems {
|
||||
var lineTotal any
|
||||
if li.Price != "" {
|
||||
if r, ok := new(big.Rat).SetString(li.Price); ok {
|
||||
qty := li.Quantity
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
r.Mul(r, big.NewRat(int64(qty), 1))
|
||||
lineTotal = r.FloatString(2)
|
||||
} else {
|
||||
lineTotal = li.Price
|
||||
}
|
||||
}
|
||||
itemPayload, _ := json.Marshal(li)
|
||||
var productID, variantID *int64
|
||||
if li.ProductID > 0 {
|
||||
id := li.ProductID
|
||||
productID = &id
|
||||
}
|
||||
if li.VariantID > 0 {
|
||||
id := li.VariantID
|
||||
variantID = &id
|
||||
}
|
||||
title := firstNonEmpty(li.Name, li.Title)
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO shopify_order_items (
|
||||
company_id, order_id, external_id, product_id, variant_id, sku, name, quantity, total, payload, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,now())
|
||||
ON CONFLICT (company_id, order_id, external_id) DO UPDATE SET
|
||||
product_id = EXCLUDED.product_id,
|
||||
variant_id = EXCLUDED.variant_id,
|
||||
sku = EXCLUDED.sku,
|
||||
name = EXCLUDED.name,
|
||||
quantity = EXCLUDED.quantity,
|
||||
total = EXCLUDED.total,
|
||||
payload = EXCLUDED.payload,
|
||||
updated_at = now()`,
|
||||
companyID, orderID, li.ID, productID, variantID, li.SKU, title, maxInt(li.Quantity, 1), lineTotal, itemPayload,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
itemsSaved++
|
||||
}
|
||||
return itemsSaved, nil
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseShopifyTime(v string) *time.Time {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{time.RFC3339, "2006-01-02T15:04:05Z", "2006-01-02T15:04:05-07:00"}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOrderListFilter(f OrderListFilter) OrderListFilter {
|
||||
if f.Limit <= 0 || f.Limit > 200 {
|
||||
f.Limit = 50
|
||||
}
|
||||
if f.Offset < 0 {
|
||||
f.Offset = 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (s *Service) ListOrders(ctx context.Context, companyID uuid.UUID, f OrderListFilter) ([]OrderRow, int, error) {
|
||||
f = normalizeOrderListFilter(f)
|
||||
where := []string{"company_id = $1"}
|
||||
args := []any{companyID}
|
||||
n := 2
|
||||
if f.Status != "" {
|
||||
where = append(where, "status = $"+itoa(n))
|
||||
args = append(args, f.Status)
|
||||
n++
|
||||
}
|
||||
if f.Email != "" {
|
||||
where = append(where, "lower(customer_email) = lower($"+itoa(n)+")")
|
||||
args = append(args, f.Email)
|
||||
n++
|
||||
}
|
||||
if f.Since != nil {
|
||||
where = append(where, "ordered_at >= $"+itoa(n))
|
||||
args = append(args, *f.Since)
|
||||
n++
|
||||
}
|
||||
clause := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM shopify_orders WHERE `+clause, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
args = append(args, f.Limit, f.Offset)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, external_id, status, currency, total::text, customer_email, customer_name, ordered_at
|
||||
FROM shopify_orders
|
||||
WHERE `+clause+`
|
||||
ORDER BY ordered_at DESC NULLS LAST, external_id DESC
|
||||
LIMIT $`+itoa(n)+` OFFSET $`+itoa(n+1), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]OrderRow, 0)
|
||||
for rows.Next() {
|
||||
var row OrderRow
|
||||
if err := rows.Scan(&row.ID, &row.ExternalID, &row.Status, &row.Currency, &row.Total, &row.CustomerEmail, &row.CustomerName, &row.OrderedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
Reference in New Issue
Block a user