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:
736
scripts/migrate_guest_orders.py
Normal file
736
scripts/migrate_guest_orders.py
Normal file
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WooCommerce GUEST CHECKOUT to Saleor Migration
|
||||
==============================================
|
||||
|
||||
For stores without customer accounts. All customer data comes from order fields.
|
||||
|
||||
Two approaches:
|
||||
1. PURE GUEST: Orders only, no customer accounts created
|
||||
2. HYBRID (Recommended): Create customer accounts from unique emails, link orders
|
||||
|
||||
Recommended: HYBRID - customers can later claim their account via password reset
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Set
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
|
||||
import psycopg2
|
||||
|
||||
# Database configurations
|
||||
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'),
|
||||
}
|
||||
|
||||
ORDER_STATUS_MAP = {
|
||||
'wc-pending': 'UNCONFIRMED',
|
||||
'wc-processing': 'UNFULFILLED',
|
||||
'wc-on-hold': 'UNCONFIRMED',
|
||||
'wc-completed': 'FULFILLED',
|
||||
'wc-cancelled': 'CANCELED',
|
||||
'wc-refunded': 'REFUNDED',
|
||||
'wc-failed': 'CANCELED',
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuestCustomer:
|
||||
"""Customer derived from order data"""
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: Optional[str]
|
||||
orders_count: int
|
||||
total_spent: float
|
||||
first_order_date: datetime
|
||||
last_order_date: datetime
|
||||
billing_address: Optional[Dict]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuestOrder:
|
||||
"""Order with embedded customer data"""
|
||||
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
|
||||
subtotal: float
|
||||
tax: float
|
||||
shipping: float
|
||||
currency: str
|
||||
payment_method: str
|
||||
payment_method_title: str
|
||||
transaction_id: Optional[str]
|
||||
billing_address: Dict
|
||||
shipping_address: Dict
|
||||
customer_note: str
|
||||
shipping_method: str
|
||||
items: List[Dict]
|
||||
|
||||
|
||||
class GuestOrderExporter:
|
||||
"""Export orders from WooCommerce (guest checkout only)"""
|
||||
|
||||
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. Install: pip install pymysql")
|
||||
|
||||
def get_unique_customers(self) -> List[GuestCustomer]:
|
||||
"""Extract unique customers from order billing data"""
|
||||
query = """
|
||||
SELECT
|
||||
meta_email.meta_value as email,
|
||||
MAX(meta_first.meta_value) as first_name,
|
||||
MAX(meta_last.meta_value) as last_name,
|
||||
MAX(meta_phone.meta_value) as phone,
|
||||
COUNT(DISTINCT p.ID) as orders_count,
|
||||
SUM(CAST(COALESCE(meta_total.meta_value, 0) AS DECIMAL(12,2))) as total_spent,
|
||||
MIN(p.post_date) as first_order_date,
|
||||
MAX(p.post_date) as last_order_date
|
||||
FROM wp_posts p
|
||||
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_total ON p.ID = meta_total.post_id
|
||||
AND meta_total.meta_key = '_order_total'
|
||||
WHERE p.post_type = 'shop_order'
|
||||
AND meta_email.meta_value IS NOT NULL
|
||||
AND meta_email.meta_value != ''
|
||||
GROUP BY meta_email.meta_value
|
||||
HAVING meta_email.meta_value LIKE '%@%'
|
||||
ORDER BY orders_count DESC
|
||||
"""
|
||||
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
customers = []
|
||||
for row in rows:
|
||||
# Get address from most recent order
|
||||
address = self._get_latest_address(row['email'])
|
||||
|
||||
customer = GuestCustomer(
|
||||
email=row['email'],
|
||||
first_name=row['first_name'] or '',
|
||||
last_name=row['last_name'] or '',
|
||||
phone=row['phone'],
|
||||
orders_count=row['orders_count'],
|
||||
total_spent=float(row['total_spent'] or 0),
|
||||
first_order_date=row['first_order_date'],
|
||||
last_order_date=row['last_order_date'],
|
||||
billing_address=address
|
||||
)
|
||||
customers.append(customer)
|
||||
|
||||
return customers
|
||||
|
||||
def _get_latest_address(self, email: str) -> Optional[Dict]:
|
||||
"""Get the most recent address for an email"""
|
||||
query = """
|
||||
SELECT
|
||||
p.ID as order_id,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_first_name' THEN pm.meta_value END) as first_name,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_last_name' THEN pm.meta_value END) as last_name,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_company' THEN pm.meta_value END) as company,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_address_1' THEN pm.meta_value END) as address_1,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_address_2' THEN pm.meta_value END) as address_2,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_city' THEN pm.meta_value END) as city,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_postcode' THEN pm.meta_value END) as postcode,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_country' THEN pm.meta_value END) as country,
|
||||
MAX(CASE WHEN pm.meta_key = '_billing_phone' THEN pm.meta_value END) as phone
|
||||
FROM wp_posts p
|
||||
JOIN wp_postmeta pm_email ON p.ID = pm_email.post_id
|
||||
AND pm_email.meta_key = '_billing_email'
|
||||
AND pm_email.meta_value = %s
|
||||
LEFT JOIN wp_postmeta pm ON p.ID = pm.post_id
|
||||
WHERE p.post_type = 'shop_order'
|
||||
GROUP BY p.ID
|
||||
ORDER BY p.post_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute(query, (email,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
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 get_orders(self, limit: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
email: Optional[str] = None) -> List[GuestOrder]:
|
||||
"""Fetch orders with embedded customer data"""
|
||||
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_payment_method.meta_value as payment_method,
|
||||
meta_payment_title.meta_value as payment_method_title,
|
||||
meta_transaction_id.meta_value as transaction_id,
|
||||
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_payment_method ON p.ID = meta_payment_method.post_id AND meta_payment_method.meta_key = '_payment_method'
|
||||
LEFT JOIN wp_postmeta meta_payment_title ON p.ID = meta_payment_title.post_id AND meta_payment_title.meta_key = '_payment_method_title'
|
||||
LEFT JOIN wp_postmeta meta_transaction_id ON p.ID = meta_transaction_id.post_id AND meta_transaction_id.meta_key = '_transaction_id'
|
||||
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)
|
||||
|
||||
if email:
|
||||
query += " AND meta_email.meta_value = %s"
|
||||
params.append(email)
|
||||
|
||||
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:
|
||||
# Get full addresses for this order
|
||||
billing = self._get_order_address(row['wc_order_id'], 'billing')
|
||||
shipping = self._get_order_address(row['wc_order_id'], 'shipping')
|
||||
items = self._get_order_items(row['wc_order_id'])
|
||||
|
||||
order = GuestOrder(
|
||||
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, # Convert to cents
|
||||
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',
|
||||
payment_method=row['payment_method'] or '',
|
||||
payment_method_title=row['payment_method_title'] or '',
|
||||
transaction_id=row['transaction_id'],
|
||||
shipping_method=row['shipping_method'] or '',
|
||||
customer_note=row['customer_note'] or '',
|
||||
billing_address=billing or self._empty_address(),
|
||||
shipping_address=shipping or billing or self._empty_address(),
|
||||
items=items
|
||||
)
|
||||
orders.append(order)
|
||||
|
||||
return orders
|
||||
|
||||
def _get_order_address(self, order_id: int, prefix: str) -> Optional[Dict]:
|
||||
"""Fetch order address from postmeta"""
|
||||
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 empty address structure"""
|
||||
return {
|
||||
'first_name': '', 'last_name': '', 'company_name': '',
|
||||
'street_address_1': '', 'street_address_2': '',
|
||||
'city': '', 'postal_code': '', 'country': 'RS', 'phone': ''
|
||||
}
|
||||
|
||||
def _get_order_items(self, order_id: int) -> List[Dict]:
|
||||
"""Fetch order line items"""
|
||||
query = """
|
||||
SELECT
|
||||
oi.order_item_name as name,
|
||||
meta_product_id.meta_value as product_id,
|
||||
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_product_id
|
||||
ON oi.order_item_id = meta_product_id.order_item_id
|
||||
AND meta_product_id.meta_key = '_product_id'
|
||||
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({
|
||||
'product_id': int(row['product_id'] or 0),
|
||||
'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 GuestSaleorImporter:
|
||||
"""Import guest 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_existing_mappings()
|
||||
|
||||
def _ensure_tables(self):
|
||||
"""Create mapping tables"""
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wc_guest_customer_mapping (
|
||||
email VARCHAR(255) PRIMARY KEY,
|
||||
saleor_user_id UUID NOT NULL,
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
phone VARCHAR(255),
|
||||
orders_count INTEGER DEFAULT 0,
|
||||
total_spent DECIMAL(12,2) 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_existing_mappings(self):
|
||||
"""Load existing email→user mappings"""
|
||||
with self.conn.cursor() as cursor:
|
||||
cursor.execute("SELECT email, saleor_user_id FROM wc_guest_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_customer_from_email(self, customer: GuestCustomer,
|
||||
dry_run: bool = False) -> Optional[uuid.UUID]:
|
||||
"""Create a Saleor user from order-derived customer data"""
|
||||
if customer.email in self.email_to_user_id:
|
||||
return self.email_to_user_id[customer.email]
|
||||
|
||||
new_user_id = uuid.uuid4()
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would create user: {customer.email}")
|
||||
return new_user_id
|
||||
|
||||
with self.conn.cursor() as cursor:
|
||||
# Create user with unusable password
|
||||
cursor.execute("""
|
||||
INSERT INTO account_user (
|
||||
id, email, first_name, last_name,
|
||||
is_staff, is_active, date_joined,
|
||||
last_login, password
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
new_user_id, customer.email, customer.first_name, customer.last_name,
|
||||
False, True, customer.first_order_date, None, '!'
|
||||
))
|
||||
|
||||
# Create address if available
|
||||
if customer.billing_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,
|
||||
customer.billing_address['first_name'],
|
||||
customer.billing_address['last_name'],
|
||||
customer.billing_address['company_name'],
|
||||
customer.billing_address['street_address_1'],
|
||||
customer.billing_address['street_address_2'],
|
||||
customer.billing_address['city'],
|
||||
customer.billing_address['postal_code'],
|
||||
customer.billing_address['country'],
|
||||
customer.billing_address['phone']
|
||||
))
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO account_user_addresses (user_id, address_id)
|
||||
VALUES (%s, %s)
|
||||
""", (new_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, new_user_id))
|
||||
|
||||
# Record mapping
|
||||
cursor.execute("""
|
||||
INSERT INTO wc_guest_customer_mapping
|
||||
(email, saleor_user_id, first_name, last_name, phone, orders_count, total_spent)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (customer.email, new_user_id, customer.first_name, customer.last_name,
|
||||
customer.phone, customer.orders_count, customer.total_spent))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
self.email_to_user_id[customer.email] = new_user_id
|
||||
print(f" Created user: {customer.email} ({customer.orders_count} orders)")
|
||||
return new_user_id
|
||||
|
||||
def import_order(self, order: GuestOrder, mode: str = 'hybrid',
|
||||
dry_run: bool = False) -> Optional[uuid.UUID]:
|
||||
"""Import an order
|
||||
|
||||
mode: 'guest' = no user account, 'hybrid' = link to created user
|
||||
"""
|
||||
# Check if already migrated
|
||||
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, skipping")
|
||||
return None
|
||||
|
||||
new_order_id = uuid.uuid4()
|
||||
channel_id = self.get_channel_id()
|
||||
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
|
||||
|
||||
# Get or create user ID
|
||||
user_id = None
|
||||
if mode == 'hybrid' and order.customer_email:
|
||||
user_id = self.email_to_user_id.get(order.customer_email)
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would create order: {order.order_number}")
|
||||
return new_order_id
|
||||
|
||||
with self.conn.cursor() as cursor:
|
||||
# Create billing address record
|
||||
billing_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)
|
||||
""", (
|
||||
billing_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 record
|
||||
shipping_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)
|
||||
""", (
|
||||
shipping_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, private_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, %s)
|
||||
""", (
|
||||
new_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,
|
||||
billing_id, shipping_id,
|
||||
json.dumps(order.billing_address), json.dumps(order.shipping_address),
|
||||
json.dumps({'woo_order_id': order.wc_order_id, 'guest_checkout': True}),
|
||||
'{}',
|
||||
'BULK_CREATE', False, False, 0.0, True, order.customer_note
|
||||
))
|
||||
|
||||
# Insert order lines
|
||||
for item in order.items:
|
||||
# Look up variant by SKU
|
||||
cursor.execute("SELECT id FROM product_productvariant WHERE sku = %s",
|
||||
(item['sku'],))
|
||||
variant_row = cursor.fetchone()
|
||||
variant_id = variant_row[0] if variant_row 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(), new_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
|
||||
))
|
||||
|
||||
# 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, new_order_id, order.customer_email))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
user_info = f" (user: {user_id})" if user_id else " (guest)"
|
||||
print(f" Created order: {order.order_number}{user_info}")
|
||||
return new_order_id
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Migrate WooCommerce Guest Orders to Saleor')
|
||||
parser.add_argument('--customers', action='store_true',
|
||||
help='Create customer accounts from unique emails')
|
||||
parser.add_argument('--orders', action='store_true', help='Migrate orders')
|
||||
parser.add_argument('--mode', choices=['guest', 'hybrid'], default='hybrid',
|
||||
help='guest=orders only, hybrid=create customers and link orders')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Preview changes')
|
||||
parser.add_argument('--limit', type=int, help='Limit records')
|
||||
parser.add_argument('--status', type=str, help='Filter by order status')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.customers and not args.orders:
|
||||
print("Please specify --customers and/or --orders")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
print("=== WooCommerce Guest Orders to Saleor Migration ===\n")
|
||||
|
||||
# Connect
|
||||
print("Connecting to databases...")
|
||||
try:
|
||||
exporter = GuestOrderExporter(WP_DB_CONFIG)
|
||||
importer = GuestSaleorImporter(SALEOR_DB_CONFIG)
|
||||
print("Connected!\n")
|
||||
except Exception as e:
|
||||
print(f"Connection failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create customers first (if hybrid mode)
|
||||
if args.customers or (args.orders and args.mode == 'hybrid'):
|
||||
print("Extracting unique customers from orders...")
|
||||
customers = exporter.get_unique_customers()
|
||||
print(f"Found {len(customers)} unique customers\n")
|
||||
|
||||
print("Creating customer accounts...")
|
||||
for i, customer in enumerate(customers, 1):
|
||||
print(f"[{i}/{len(customers)}] {customer.email}")
|
||||
try:
|
||||
importer.create_customer_from_email(customer, dry_run=args.dry_run)
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
print(f"\nCustomer creation {'preview' if args.dry_run else 'complete'}!\n")
|
||||
|
||||
# Migrate orders
|
||||
if args.orders:
|
||||
print("Fetching orders...")
|
||||
orders = exporter.get_orders(limit=args.limit, status=args.status)
|
||||
print(f"Found {len(orders)} orders\n")
|
||||
|
||||
print(f"Migrating orders (mode: {args.mode})...")
|
||||
for i, order in enumerate(orders, 1):
|
||||
print(f"[{i}/{len(orders)}] {order.order_number} - {order.customer_email}")
|
||||
try:
|
||||
importer.import_order(order, mode=args.mode, dry_run=args.dry_run)
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
print(f"\nOrder migration {'preview' if args.dry_run else 'complete'}!\n")
|
||||
|
||||
print("=== Summary ===")
|
||||
print(f"Customers: {len(importer.email_to_user_id)}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user