feat(saleor): Phase 1 - GraphQL Client Setup

- Add Apollo Client for Saleor GraphQL API
- Create GraphQL fragments (Product, Variant, Checkout)
- Create GraphQL queries (Products, Checkout)
- Create GraphQL mutations (Checkout operations)
- Add TypeScript types for Saleor entities
- Add product helper functions
- Install @apollo/client and graphql dependencies

Part of WordPress/WooCommerce → Saleor migration
This commit is contained in:
Unchained
2026-03-21 12:36:21 +02:00
parent db1914d69b
commit 7b94537670
27 changed files with 7879 additions and 3 deletions

View File

@@ -0,0 +1,576 @@
#!/usr/bin/env python3
"""
WooCommerce CASH ON DELIVERY Orders to Saleor Migration
=======================================================
For stores with COD only - no payment gateway, no transaction IDs.
Payment is collected on delivery, so payment status = fulfillment status.
Key differences from card payments:
- No payment_method details needed (or set to 'mirumee.payments.dummy')
- No transaction IDs
- Payment is marked as received when order is fulfilled
- Simpler order structure
"""
import os
import sys
import json
import uuid
import argparse
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass
import psycopg2
WP_DB_CONFIG = {
'host': os.getenv('WP_DB_HOST', 'localhost'),
'port': int(os.getenv('WP_DB_PORT', 3306)),
'user': os.getenv('WP_DB_USER', 'wordpress'),
'password': os.getenv('WP_DB_PASSWORD', ''),
'database': os.getenv('WP_DB_NAME', 'wordpress'),
}
SALEOR_DB_CONFIG = {
'host': os.getenv('SALEOR_DB_HOST', 'localhost'),
'port': int(os.getenv('SALEOR_DB_PORT', 5432)),
'user': os.getenv('SALEOR_DB_USER', 'saleor'),
'password': os.getenv('SALEOR_DB_PASSWORD', ''),
'database': os.getenv('SALEOR_DB_NAME', 'saleor'),
}
# COD Status Mapping
# WC: wc-pending -> Saleor: UNCONFIRMED (order received, not processed)
# WC: wc-processing -> Saleor: UNFULFILLED (preparing for delivery)
# WC: wc-completed -> Saleor: FULFILLED + payment marked as received
ORDER_STATUS_MAP = {
'wc-pending': 'UNCONFIRMED',
'wc-processing': 'UNFULFILLED',
'wc-on-hold': 'UNCONFIRMED',
'wc-completed': 'FULFILLED',
'wc-cancelled': 'CANCELED',
'wc-refunded': 'CANCELED', # COD refunds are manual
'wc-failed': 'CANCELED',
}
@dataclass
class CODOrder:
"""COD Order with minimal payment info"""
wc_order_id: int
order_number: str
status: str
date_created: datetime
date_modified: datetime
customer_email: str
customer_first_name: str
customer_last_name: str
customer_phone: Optional[str]
total: float # in cents
subtotal: float
tax: float
shipping: float
currency: str
billing_address: Dict
shipping_address: Dict
customer_note: str
shipping_method: str
items: List[Dict]
is_paid: bool # Derived from status (completed = paid)
class CODOrderExporter:
"""Export COD orders from WooCommerce"""
def __init__(self, wp_db_config: Dict):
try:
import pymysql
self.conn = pymysql.connect(
host=wp_db_config['host'],
port=wp_db_config['port'],
user=wp_db_config['user'],
password=wp_db_config['password'],
database=wp_db_config['database'],
cursorclass=pymysql.cursors.DictCursor
)
except ImportError:
raise ImportError("pymysql required")
def get_orders(self, limit: Optional[int] = None,
status: Optional[str] = None) -> List[CODOrder]:
"""Fetch COD orders"""
query = """
SELECT
p.ID as wc_order_id,
p.post_date as date_created,
p.post_modified as date_modified,
p.post_status as status,
meta_total.meta_value as total,
meta_subtotal.meta_value as subtotal,
meta_tax.meta_value as tax,
meta_shipping.meta_value as shipping,
meta_currency.meta_value as currency,
meta_email.meta_value as customer_email,
meta_first.meta_value as customer_first_name,
meta_last.meta_value as customer_last_name,
meta_phone.meta_value as customer_phone,
meta_shipping_method.meta_value as shipping_method,
meta_customer_note.meta_value as customer_note
FROM wp_posts p
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id
AND meta_total.meta_key = '_order_total'
LEFT JOIN wp_postmeta meta_subtotal ON p.ID = meta_subtotal.post_id
AND meta_subtotal.meta_key = '_order_subtotal'
LEFT JOIN wp_postmeta meta_tax ON p.ID = meta_tax.post_id
AND meta_tax.meta_key = '_order_tax'
LEFT JOIN wp_postmeta meta_shipping ON p.ID = meta_shipping.post_id
AND meta_shipping.meta_key = '_order_shipping'
LEFT JOIN wp_postmeta meta_currency ON p.ID = meta_currency.post_id
AND meta_currency.meta_key = '_order_currency'
LEFT JOIN wp_postmeta meta_email ON p.ID = meta_email.post_id
AND meta_email.meta_key = '_billing_email'
LEFT JOIN wp_postmeta meta_first ON p.ID = meta_first.post_id
AND meta_first.meta_key = '_billing_first_name'
LEFT JOIN wp_postmeta meta_last ON p.ID = meta_last.post_id
AND meta_last.meta_key = '_billing_last_name'
LEFT JOIN wp_postmeta meta_phone ON p.ID = meta_phone.post_id
AND meta_phone.meta_key = '_billing_phone'
LEFT JOIN wp_postmeta meta_shipping_method ON p.ID = meta_shipping_method.post_id
AND meta_shipping_method.meta_key = '_shipping_method'
LEFT JOIN wp_postmeta meta_customer_note ON p.ID = meta_customer_note.post_id
AND meta_customer_note.meta_key = 'customer_note'
WHERE p.post_type = 'shop_order'
"""
params = []
if status:
query += " AND p.post_status = %s"
params.append(status)
query += " ORDER BY p.post_date DESC"
if limit:
query += f" LIMIT {limit}"
with self.conn.cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
orders = []
for row in rows:
billing = self._get_address(row['wc_order_id'], 'billing')
shipping = self._get_address(row['wc_order_id'], 'shipping')
items = self._get_items(row['wc_order_id'])
# For COD: order is paid when status is completed
is_paid = row['status'] == 'wc-completed'
order = CODOrder(
wc_order_id=row['wc_order_id'],
order_number=f"WC-{row['wc_order_id']}",
status=row['status'],
date_created=row['date_created'],
date_modified=row['date_modified'],
customer_email=row['customer_email'] or '',
customer_first_name=row['customer_first_name'] or '',
customer_last_name=row['customer_last_name'] or '',
customer_phone=row['customer_phone'],
total=float(row['total'] or 0) * 100,
subtotal=float(row['subtotal'] or 0) * 100,
tax=float(row['tax'] or 0) * 100,
shipping=float(row['shipping'] or 0) * 100,
currency=row['currency'] or 'RSD',
billing_address=billing or self._empty_address(),
shipping_address=shipping or billing or self._empty_address(),
shipping_method=row['shipping_method'] or 'Cash on Delivery',
customer_note=row['customer_note'] or '',
items=items,
is_paid=is_paid
)
orders.append(order)
return orders
def _get_address(self, order_id: int, prefix: str) -> Optional[Dict]:
query = f"""
SELECT
MAX(CASE WHEN meta_key = '_{prefix}_first_name' THEN meta_value END) as first_name,
MAX(CASE WHEN meta_key = '_{prefix}_last_name' THEN meta_value END) as last_name,
MAX(CASE WHEN meta_key = '_{prefix}_company' THEN meta_value END) as company,
MAX(CASE WHEN meta_key = '_{prefix}_address_1' THEN meta_value END) as address_1,
MAX(CASE WHEN meta_key = '_{prefix}_address_2' THEN meta_value END) as address_2,
MAX(CASE WHEN meta_key = '_{prefix}_city' THEN meta_value END) as city,
MAX(CASE WHEN meta_key = '_{prefix}_postcode' THEN meta_value END) as postcode,
MAX(CASE WHEN meta_key = '_{prefix}_country' THEN meta_value END) as country,
MAX(CASE WHEN meta_key = '_{prefix}_phone' THEN meta_value END) as phone
FROM wp_postmeta
WHERE post_id = %s
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
row = cursor.fetchone()
if not row or not row['first_name']:
return None
return {
'first_name': row['first_name'] or '',
'last_name': row['last_name'] or '',
'company_name': row['company'] or '',
'street_address_1': row['address_1'] or '',
'street_address_2': row['address_2'] or '',
'city': row['city'] or '',
'postal_code': row['postcode'] or '',
'country': row['country'] or 'RS',
'phone': row['phone'] or '',
}
def _empty_address(self) -> Dict:
return {
'first_name': '', 'last_name': '', 'company_name': '',
'street_address_1': '', 'street_address_2': '',
'city': '', 'postal_code': '', 'country': 'RS', 'phone': ''
}
def _get_items(self, order_id: int) -> List[Dict]:
query = """
SELECT
oi.order_item_name as name,
meta_sku.meta_value as sku,
meta_qty.meta_value as quantity,
meta_subtotal.meta_value as subtotal,
meta_total.meta_value as total,
meta_tax.meta_value as tax
FROM wp_woocommerce_order_items oi
LEFT JOIN wp_woocommerce_order_itemmeta meta_sku
ON oi.order_item_id = meta_sku.order_item_id
AND meta_sku.meta_key = '_sku'
LEFT JOIN wp_woocommerce_order_itemmeta meta_qty
ON oi.order_item_id = meta_qty.order_item_id
AND meta_qty.meta_key = '_qty'
LEFT JOIN wp_woocommerce_order_itemmeta meta_subtotal
ON oi.order_item_id = meta_subtotal.order_item_id
AND meta_subtotal.meta_key = '_line_subtotal'
LEFT JOIN wp_woocommerce_order_itemmeta meta_total
ON oi.order_item_id = meta_total.order_item_id
AND meta_total.meta_key = '_line_total'
LEFT JOIN wp_woocommerce_order_itemmeta meta_tax
ON oi.order_item_id = meta_tax.order_item_id
AND meta_tax.meta_key = '_line_tax'
WHERE oi.order_id = %s AND oi.order_item_type = 'line_item'
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (order_id,))
rows = cursor.fetchall()
items = []
for row in rows:
qty = int(row['quantity'] or 1)
items.append({
'name': row['name'] or '',
'sku': row['sku'] or '',
'quantity': qty,
'subtotal': float(row['subtotal'] or 0) * 100,
'total': float(row['total'] or 0) * 100,
'tax': float(row['tax'] or 0) * 100,
})
return items
class CODSaleorImporter:
"""Import COD orders into Saleor"""
def __init__(self, saleor_db_config: Dict):
self.conn = psycopg2.connect(
host=saleor_db_config['host'],
port=saleor_db_config['port'],
user=saleor_db_config['user'],
password=saleor_db_config['password'],
database=saleor_db_config['database']
)
self.email_to_user_id: Dict[str, uuid.UUID] = {}
self._ensure_tables()
self._load_mappings()
def _ensure_tables(self):
with self.conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_cod_customer_mapping (
email VARCHAR(255) PRIMARY KEY,
saleor_user_id UUID NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
phone VARCHAR(255),
order_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_order_mapping (
wc_order_id BIGINT PRIMARY KEY,
saleor_order_id UUID NOT NULL,
customer_email VARCHAR(255),
migrated_at TIMESTAMP DEFAULT NOW()
);
""")
self.conn.commit()
def _load_mappings(self):
with self.conn.cursor() as cursor:
cursor.execute("SELECT email, saleor_user_id FROM wc_cod_customer_mapping")
for row in cursor.fetchall():
self.email_to_user_id[row[0]] = row[1]
def get_channel_id(self) -> uuid.UUID:
with self.conn.cursor() as cursor:
cursor.execute("SELECT id FROM channel_channel WHERE slug = 'default-channel' LIMIT 1")
return cursor.fetchone()[0]
def create_user(self, email: str, first_name: str, last_name: str,
phone: Optional[str], address: Dict, dry_run: bool = False) -> uuid.UUID:
"""Create a customer user from order data"""
if email in self.email_to_user_id:
return self.email_to_user_id[email]
user_id = uuid.uuid4()
if dry_run:
print(f" [DRY RUN] Would create user: {email}")
return user_id
with self.conn.cursor() as cursor:
# Create user
cursor.execute("""
INSERT INTO account_user (id, email, first_name, last_name,
is_staff, is_active, date_joined, password)
VALUES (%s, %s, %s, %s, %s, %s, NOW(), %s)
""", (user_id, email, first_name, last_name, False, True, '!'))
# Create address
addr_id = uuid.uuid4()
cursor.execute("""
INSERT INTO account_address (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (addr_id, address['first_name'], address['last_name'],
address['company_name'], address['street_address_1'],
address['street_address_2'], address['city'],
address['postal_code'], address['country'], phone or ''))
cursor.execute("""
INSERT INTO account_user_addresses (user_id, address_id)
VALUES (%s, %s)
""", (user_id, addr_id))
cursor.execute("""
UPDATE account_user
SET default_billing_address_id = %s, default_shipping_address_id = %s
WHERE id = %s
""", (addr_id, addr_id, user_id))
cursor.execute("""
INSERT INTO wc_cod_customer_mapping (email, saleor_user_id, first_name, last_name, phone)
VALUES (%s, %s, %s, %s, %s)
""", (email, user_id, first_name, last_name, phone))
self.conn.commit()
self.email_to_user_id[email] = user_id
return user_id
def import_order(self, order: CODOrder, create_users: bool = True,
dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import a COD order"""
# Check existing
with self.conn.cursor() as cursor:
cursor.execute("SELECT saleor_order_id FROM wc_order_mapping WHERE wc_order_id = %s",
(order.wc_order_id,))
if cursor.fetchone():
print(f" Order {order.order_number} already migrated")
return None
order_id = uuid.uuid4()
channel_id = self.get_channel_id()
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
# Get or create user
user_id = None
if create_users and order.customer_email:
if order.customer_email not in self.email_to_user_id:
self.create_user(order.customer_email, order.customer_first_name,
order.customer_last_name, order.customer_phone,
order.billing_address, dry_run)
user_id = self.email_to_user_id.get(order.customer_email)
if dry_run:
paid_status = "PAID" if order.is_paid else "UNPAID"
print(f" [DRY RUN] Would create order: {order.order_number} ({paid_status})")
return order_id
with self.conn.cursor() as cursor:
# Create billing address
bill_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_orderbillingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (bill_id, order.billing_address['first_name'], order.billing_address['last_name'],
order.billing_address['company_name'], order.billing_address['street_address_1'],
order.billing_address['street_address_2'], order.billing_address['city'],
order.billing_address['postal_code'], order.billing_address['country'],
order.billing_address['phone']))
# Create shipping address
ship_id = uuid.uuid4()
cursor.execute("""
INSERT INTO order_ordershippingaddress (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (ship_id, order.shipping_address['first_name'], order.shipping_address['last_name'],
order.shipping_address['company_name'], order.shipping_address['street_address_1'],
order.shipping_address['street_address_2'], order.shipping_address['city'],
order.shipping_address['postal_code'], order.shipping_address['country'],
order.shipping_address['phone']))
# Insert order
cursor.execute("""
INSERT INTO order_order (
id, created_at, updated_at, status, user_email, user_id, currency,
total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address_id, shipping_address_id,
billing_address, shipping_address,
metadata, origin, should_refresh_prices,
tax_exemption, discount_amount, display_gross_prices, customer_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
order_id, order.date_created, order.date_modified, saleor_status,
order.customer_email, user_id, order.currency,
order.total, order.subtotal, order.shipping, order.shipping,
order.shipping_method, channel_id, bill_id, ship_id,
json.dumps(order.billing_address), json.dumps(order.shipping_address),
json.dumps({
'woo_order_id': order.wc_order_id,
'cod_payment': True,
'payment_collected_on_delivery': order.is_paid
}),
'BULK_CREATE', False, False, 0.0, True, order.customer_note
))
# Insert order lines
for item in order.items:
cursor.execute("SELECT id FROM product_productvariant WHERE sku = %s",
(item['sku'],))
variant = cursor.fetchone()
variant_id = variant[0] if variant else None
qty = item['quantity']
unit_net = item['subtotal'] / qty if qty else 0
unit_gross = (item['subtotal'] + item['tax']) / qty if qty else 0
cursor.execute("""
INSERT INTO order_orderline (id, order_id, product_name, product_sku,
quantity, currency, unit_price_net_amount, unit_price_gross_amount,
total_price_net_amount, total_price_gross_amount,
unit_discount_amount, unit_discount_type, tax_rate,
is_shipping_required, variant_id, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (uuid.uuid4(), order_id, item['name'], item['sku'], qty,
order.currency, unit_net, unit_gross, item['subtotal'],
item['subtotal'] + item['tax'], 0.0, 'FIXED', '0.15',
True, variant_id, order.date_created))
# For COD: Create a dummy payment record for completed orders
# This marks that payment was collected on delivery
if order.is_paid:
cursor.execute("""
INSERT INTO payment_payment (
id, gateway, is_active, to_confirm, order_id, total,
captured_amount, currency, charge_status, partial, modified_at, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
uuid.uuid4(),
'mirumee.payments.dummy', # Dummy gateway for COD
False, # Not active (completed)
False,
order_id,
order.total,
order.total, # Fully captured (collected on delivery)
order.currency,
'FULLY_CHARGED',
False,
order.date_modified,
order.date_created
))
# Record mapping
cursor.execute("""
INSERT INTO wc_order_mapping (wc_order_id, saleor_order_id, customer_email)
VALUES (%s, %s, %s)
""", (order.wc_order_id, order_id, order.customer_email))
self.conn.commit()
paid_marker = "" if order.is_paid else ""
print(f" Created order: {order.order_number} {paid_marker}")
return order_id
def main():
parser = argparse.ArgumentParser(description='Migrate WooCommerce COD Orders to Saleor')
parser.add_argument('--orders', action='store_true', help='Migrate orders')
parser.add_argument('--create-users', action='store_true',
help='Create customer accounts from order emails')
parser.add_argument('--dry-run', action='store_true', help='Preview only')
parser.add_argument('--limit', type=int, help='Limit order count')
parser.add_argument('--status', type=str, help='Filter by status (wc-completed, etc)')
args = parser.parse_args()
if not args.orders:
parser.print_help()
sys.exit(1)
print("=== WooCommerce COD Orders to Saleor Migration ===\n")
print("Connecting...")
try:
exporter = CODOrderExporter(WP_DB_CONFIG)
importer = CODSaleorImporter(SALEOR_DB_CONFIG)
print("Connected!\n")
except Exception as e:
print(f"Failed: {e}")
sys.exit(1)
print("Fetching orders...")
orders = exporter.get_orders(limit=args.limit, status=args.status)
print(f"Found {len(orders)} orders\n")
# Stats
paid_count = sum(1 for o in orders if o.is_paid)
unpaid_count = len(orders) - paid_count
print(f"Breakdown: {paid_count} paid (delivered), {unpaid_count} unpaid (pending/processing)\n")
print("Migrating...")
for i, order in enumerate(orders, 1):
status_marker = "" if order.is_paid else ""
print(f"[{i}/{len(orders)}] {order.order_number} {status_marker} {order.customer_email}")
try:
importer.import_order(order, create_users=args.create_users, dry_run=args.dry_run)
except Exception as e:
print(f" ERROR: {e}")
print(f"\n{'Preview' if args.dry_run else 'Migration'} complete!")
print(f"Total orders: {len(orders)}")
if args.create_users:
print(f"Customers created: {len(importer.email_to_user_id)}")
if __name__ == '__main__':
main()