296 lines
7.6 KiB
Go
296 lines
7.6 KiB
Go
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)
|
||
|
|
}
|