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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+295
View File
@@ -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)
}