- 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
786 lines
35 KiB
Python
786 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
COMPLETE WooCommerce to Saleor Migration
|
|
========================================
|
|
|
|
Migrates:
|
|
1. ALL 4,886 WordPress users (including 3,700+ who never ordered = PROSPECTS)
|
|
2. ALL 1,786 orders linked to customers by email
|
|
|
|
Principles:
|
|
- Every WP user becomes a Saleor customer (prospects for marketing)
|
|
- Orders linked by email (catches "guest" checkouts too)
|
|
- Pending/processing/completed = FULFILLED (COD collected)
|
|
- Cancelled = CANCELLED (but still linked to customer)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import uuid
|
|
import argparse
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Set, Tuple
|
|
from dataclasses import dataclass
|
|
from collections import defaultdict
|
|
|
|
import psycopg2
|
|
|
|
WP_DB_CONFIG = {
|
|
'host': os.getenv('WP_DB_HOST', '10.43.245.156'),
|
|
'port': int(os.getenv('WP_DB_PORT', 3306)),
|
|
'user': os.getenv('WP_DB_USER', 'DUjqYuqsYvaGUFV4'),
|
|
'password': os.getenv('WP_DB_PASSWORD', 'voP0UzecALE0WRNJQcTCf0STMcxIiX99'),
|
|
'database': os.getenv('WP_DB_NAME', 'wordpress'),
|
|
}
|
|
|
|
SALEOR_DB_CONFIG = {
|
|
'host': os.getenv('SALEOR_DB_HOST', '10.43.42.251'),
|
|
'port': int(os.getenv('SALEOR_DB_PORT', 5432)),
|
|
'user': os.getenv('SALEOR_DB_USER', 'saleor'),
|
|
'password': os.getenv('SALEOR_DB_PASSWORD', 'saleor123'),
|
|
'database': os.getenv('SALEOR_DB_NAME', 'saleor'),
|
|
}
|
|
|
|
ORDER_STATUS_MAP = {
|
|
'wc-pending': 'FULFILLED',
|
|
'wc-processing': 'FULFILLED',
|
|
'wc-on-hold': 'FULFILLED',
|
|
'wc-completed': 'FULFILLED',
|
|
'wc-cancelled': 'CANCELED',
|
|
'wc-refunded': 'CANCELED',
|
|
'wc-failed': 'CANCELED',
|
|
}
|
|
|
|
NON_CANCELLED_STATUSES = ['wc-completed', 'wc-pending', 'wc-processing', 'wc-on-hold']
|
|
|
|
|
|
@dataclass
|
|
class Customer:
|
|
"""Customer from WP users OR order billing data"""
|
|
source: str # 'wp_user' or 'order_email'
|
|
email: str
|
|
first_name: str
|
|
last_name: str
|
|
phone: Optional[str]
|
|
date_registered: datetime
|
|
billing_address: Optional[Dict]
|
|
|
|
# Order stats (from joined data)
|
|
total_orders: int = 0
|
|
cancelled_orders: int = 0
|
|
completed_orders: int = 0
|
|
total_spent: float = 0.0
|
|
first_order_date: Optional[datetime] = None
|
|
last_order_date: Optional[datetime] = None
|
|
|
|
@property
|
|
def segment(self) -> str:
|
|
if self.completed_orders >= 4:
|
|
return "VIP"
|
|
elif self.completed_orders >= 2:
|
|
return "REPEAT"
|
|
elif self.completed_orders == 1:
|
|
return "ONE_TIME"
|
|
elif self.total_orders > 0:
|
|
return "CANCELLED_ONLY"
|
|
else:
|
|
return "PROSPECT"
|
|
|
|
|
|
@dataclass
|
|
class OrderToMigrate:
|
|
"""Order 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
|
|
billing_address: Dict
|
|
shipping_address: Dict
|
|
customer_note: str
|
|
shipping_method: str
|
|
items: List[Dict]
|
|
is_paid: bool
|
|
|
|
|
|
class CompleteExporter:
|
|
"""Export all users and orders"""
|
|
|
|
def __init__(self, wp_db_config: Dict):
|
|
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
|
|
)
|
|
|
|
def get_all_customers(self) -> Dict[str, Customer]:
|
|
"""Get ALL customers: WP users + order emails merged"""
|
|
customers: Dict[str, Customer] = {}
|
|
|
|
# Step 1: Get all WordPress users (these are prospects if no orders)
|
|
with self.conn.cursor() as cursor:
|
|
cursor.execute("""
|
|
SELECT
|
|
u.ID as wp_user_id,
|
|
u.user_email as email,
|
|
u.user_registered as date_registered,
|
|
um_first.meta_value as first_name,
|
|
um_last.meta_value as last_name,
|
|
um_phone.meta_value as phone
|
|
FROM wp_users u
|
|
LEFT JOIN wp_usermeta um_first ON u.ID = um_first.user_id AND um_first.meta_key = 'first_name'
|
|
LEFT JOIN wp_usermeta um_last ON u.ID = um_last.user_id AND um_last.meta_key = 'last_name'
|
|
LEFT JOIN wp_usermeta um_phone ON u.ID = um_phone.user_id AND um_phone.meta_key = 'billing_phone'
|
|
WHERE u.user_email IS NOT NULL AND u.user_email != ''
|
|
""")
|
|
|
|
for row in cursor.fetchall():
|
|
email = row['email'].lower().strip()
|
|
address = self._get_user_address(row['wp_user_id'])
|
|
|
|
customers[email] = Customer(
|
|
source='wp_user',
|
|
email=email,
|
|
first_name=row['first_name'] or '',
|
|
last_name=row['last_name'] or '',
|
|
phone=row['phone'],
|
|
date_registered=row['date_registered'],
|
|
billing_address=address,
|
|
total_orders=0,
|
|
cancelled_orders=0,
|
|
completed_orders=0,
|
|
total_spent=0.0
|
|
)
|
|
|
|
# Step 2: Get order stats for all customers (including those not in WP users)
|
|
with self.conn.cursor() as cursor:
|
|
cursor.execute("""
|
|
SELECT
|
|
LOWER(TRIM(pm_email.meta_value)) as email,
|
|
MAX(pm_first.meta_value) as first_name,
|
|
MAX(pm_last.meta_value) as last_name,
|
|
MAX(pm_phone.meta_value) as phone,
|
|
COUNT(*) as total_orders,
|
|
SUM(CASE WHEN p.post_status = 'wc-cancelled' THEN 1 ELSE 0 END) as cancelled_orders,
|
|
SUM(CASE WHEN p.post_status != 'wc-cancelled' THEN 1 ELSE 0 END) as completed_orders,
|
|
SUM(CASE WHEN p.post_status != 'wc-cancelled' THEN CAST(COALESCE(pm_total.meta_value, 0) AS DECIMAL(12,2)) ELSE 0 END) 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 pm_email ON p.ID = pm_email.post_id AND pm_email.meta_key = '_billing_email'
|
|
LEFT JOIN wp_postmeta pm_first ON p.ID = pm_first.post_id AND pm_first.meta_key = '_billing_first_name'
|
|
LEFT JOIN wp_postmeta pm_last ON p.ID = pm_last.post_id AND pm_last.meta_key = '_billing_last_name'
|
|
LEFT JOIN wp_postmeta pm_phone ON p.ID = pm_phone.post_id AND pm_phone.meta_key = '_billing_phone'
|
|
LEFT JOIN wp_postmeta pm_total ON p.ID = pm_total.post_id AND pm_total.meta_key = '_order_total'
|
|
WHERE p.post_type = 'shop_order'
|
|
AND pm_email.meta_value IS NOT NULL
|
|
AND pm_email.meta_value != ''
|
|
GROUP BY LOWER(TRIM(pm_email.meta_value))
|
|
""")
|
|
|
|
for row in cursor.fetchall():
|
|
email = row['email']
|
|
|
|
if email in customers:
|
|
# Update existing WP user with order stats
|
|
existing = customers[email]
|
|
existing.total_orders = row['total_orders']
|
|
existing.cancelled_orders = row['cancelled_orders']
|
|
existing.completed_orders = row['completed_orders']
|
|
existing.total_spent = float(row['total_spent'] or 0)
|
|
existing.first_order_date = row['first_order_date']
|
|
existing.last_order_date = row['last_order_date']
|
|
# Use order data for name/phone if WP data is empty
|
|
if not existing.first_name:
|
|
existing.first_name = row['first_name'] or ''
|
|
if not existing.last_name:
|
|
existing.last_name = row['last_name'] or ''
|
|
if not existing.phone:
|
|
existing.phone = row['phone']
|
|
else:
|
|
# New customer from order (guest checkout)
|
|
address = {
|
|
'first_name': row['first_name'] or '',
|
|
'last_name': row['last_name'] or '',
|
|
'company_name': '',
|
|
'street_address_1': '',
|
|
'street_address_2': '',
|
|
'city': '',
|
|
'postal_code': '',
|
|
'country': 'RS',
|
|
'phone': row['phone'] or '',
|
|
}
|
|
|
|
customers[email] = Customer(
|
|
source='order_email',
|
|
email=email,
|
|
first_name=row['first_name'] or '',
|
|
last_name=row['last_name'] or '',
|
|
phone=row['phone'],
|
|
date_registered=row['first_order_date'] or datetime.now(),
|
|
billing_address=address,
|
|
total_orders=row['total_orders'],
|
|
cancelled_orders=row['cancelled_orders'],
|
|
completed_orders=row['completed_orders'],
|
|
total_spent=float(row['total_spent'] or 0),
|
|
first_order_date=row['first_order_date'],
|
|
last_order_date=row['last_order_date']
|
|
)
|
|
|
|
return customers
|
|
|
|
def _get_user_address(self, user_id: int) -> Optional[Dict]:
|
|
"""Get address from usermeta or latest order"""
|
|
# Try usermeta first
|
|
with self.conn.cursor() as cursor:
|
|
cursor.execute("""
|
|
SELECT
|
|
MAX(CASE WHEN meta_key = 'billing_first_name' THEN meta_value END) as first_name,
|
|
MAX(CASE WHEN meta_key = 'billing_last_name' THEN meta_value END) as last_name,
|
|
MAX(CASE WHEN meta_key = 'billing_address_1' THEN meta_value END) as address_1,
|
|
MAX(CASE WHEN meta_key = 'billing_address_2' THEN meta_value END) as address_2,
|
|
MAX(CASE WHEN meta_key = 'billing_city' THEN meta_value END) as city,
|
|
MAX(CASE WHEN meta_key = 'billing_postcode' THEN meta_value END) as postcode,
|
|
MAX(CASE WHEN meta_key = 'billing_country' THEN meta_value END) as country,
|
|
MAX(CASE WHEN meta_key = 'billing_phone' THEN meta_value END) as phone
|
|
FROM wp_usermeta
|
|
WHERE user_id = %s
|
|
""", (user_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if row and row['first_name']:
|
|
return {
|
|
'first_name': row['first_name'] or '',
|
|
'last_name': row['last_name'] or '',
|
|
'company_name': '',
|
|
'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 '',
|
|
}
|
|
|
|
return None
|
|
|
|
def get_all_orders(self, limit: Optional[int] = None) -> List[OrderToMigrate]:
|
|
"""Get ALL 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,
|
|
LOWER(TRIM(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'
|
|
ORDER BY p.post_date DESC
|
|
"""
|
|
|
|
if limit:
|
|
query += f" LIMIT {limit}"
|
|
|
|
with self.conn.cursor() as cursor:
|
|
cursor.execute(query)
|
|
rows = cursor.fetchall()
|
|
|
|
orders = []
|
|
for row in rows:
|
|
billing = self._get_order_address(row['wc_order_id'], 'billing')
|
|
shipping = self._get_order_address(row['wc_order_id'], 'shipping')
|
|
items = self._get_items(row['wc_order_id'])
|
|
|
|
orders.append(OrderToMigrate(
|
|
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=row['status'] in NON_CANCELLED_STATUSES
|
|
))
|
|
|
|
return orders
|
|
|
|
def _get_order_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 CompleteImporter:
|
|
"""Import customers and orders"""
|
|
|
|
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_complete_mapping (
|
|
email VARCHAR(255) PRIMARY KEY,
|
|
saleor_user_id UUID NOT NULL,
|
|
source VARCHAR(50) NOT NULL,
|
|
segment VARCHAR(50) NOT NULL,
|
|
total_orders INTEGER DEFAULT 0,
|
|
completed_orders INTEGER DEFAULT 0,
|
|
cancelled_orders INTEGER DEFAULT 0,
|
|
total_spent DECIMAL(12,2) DEFAULT 0,
|
|
migrated_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_complete_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 import_customer(self, customer: Customer, dry_run: bool = False) -> uuid.UUID:
|
|
"""Create a customer"""
|
|
if customer.email in self.email_to_user_id:
|
|
return self.email_to_user_id[customer.email]
|
|
|
|
user_id = uuid.uuid4()
|
|
|
|
if dry_run:
|
|
status = "✅" if customer.completed_orders > 0 else "👤"
|
|
print(f" {status} [{customer.segment}] {customer.email} ({customer.source}, {customer.completed_orders} orders)")
|
|
return user_id
|
|
|
|
with self.conn.cursor() as cursor:
|
|
metadata = {
|
|
'source': customer.source,
|
|
'segment': customer.segment,
|
|
'total_orders': customer.total_orders,
|
|
'completed_orders': customer.completed_orders,
|
|
'cancelled_orders': customer.cancelled_orders,
|
|
'total_spent': float(customer.total_spent) if customer.total_spent else 0.0,
|
|
}
|
|
|
|
cursor.execute("""
|
|
INSERT INTO account_user (id, email, first_name, last_name,
|
|
is_staff, is_active, date_joined, password, metadata)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""", (
|
|
str(user_id), customer.email, customer.first_name, customer.last_name,
|
|
False, True, customer.date_registered, '!', json.dumps(metadata)
|
|
))
|
|
|
|
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)
|
|
""", (
|
|
str(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.phone or ''
|
|
))
|
|
|
|
cursor.execute("""
|
|
INSERT INTO account_user_addresses (user_id, address_id)
|
|
VALUES (%s, %s)
|
|
""", (str(user_id), str(addr_id)))
|
|
|
|
cursor.execute("""
|
|
UPDATE account_user
|
|
SET default_billing_address_id = %s, default_shipping_address_id = %s
|
|
WHERE id = %s
|
|
""", (str(addr_id), str(addr_id), str(user_id)))
|
|
|
|
cursor.execute("""
|
|
INSERT INTO wc_complete_mapping
|
|
(email, saleor_user_id, source, segment, total_orders, completed_orders, cancelled_orders, total_spent)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
""", (
|
|
customer.email, str(user_id), customer.source, customer.segment,
|
|
customer.total_orders, customer.completed_orders, customer.cancelled_orders, float(customer.total_spent) if customer.total_spent else 0.0
|
|
))
|
|
|
|
self.conn.commit()
|
|
|
|
self.email_to_user_id[customer.email] = user_id
|
|
return user_id
|
|
|
|
def import_order(self, order: OrderToMigrate, dry_run: bool = False) -> Optional[uuid.UUID]:
|
|
"""Import an order"""
|
|
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():
|
|
return None
|
|
|
|
order_id = uuid.uuid4()
|
|
channel_id = self.get_channel_id()
|
|
saleor_status = ORDER_STATUS_MAP.get(order.status, 'UNCONFIRMED')
|
|
|
|
# Get user by email
|
|
user_id = self.email_to_user_id.get(order.customer_email)
|
|
|
|
if dry_run:
|
|
marker = "✅" if order.is_paid else "❌"
|
|
linked = "→" if user_id else "⚠"
|
|
print(f" {order.order_number} {marker} {linked} {order.customer_email}")
|
|
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)
|
|
""", (str(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)
|
|
""", (str(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, str(user_id) if user_id else None, order.currency,
|
|
order.total, order.subtotal, order.shipping, order.shipping,
|
|
order.shipping_method, str(channel_id), str(bill_id), str(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': order.is_paid,
|
|
'original_status': order.status
|
|
}),
|
|
'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)
|
|
""", (str(uuid.uuid4()), str(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))
|
|
|
|
# Create payment record for paid orders
|
|
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)
|
|
""", (str(uuid.uuid4()), 'mirumee.payments.dummy', False, False,
|
|
str(order_id), order.total, order.total, 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, str(order_id), order.customer_email))
|
|
|
|
self.conn.commit()
|
|
|
|
return order_id
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Complete WooCommerce Migration - ALL 4,886 users + ALL 1,786 orders'
|
|
)
|
|
parser.add_argument('--customers', action='store_true', help='Migrate ALL 4,886 WordPress users + order customers')
|
|
parser.add_argument('--orders', action='store_true', help='Migrate ALL 1,786 orders')
|
|
parser.add_argument('--dry-run', action='store_true', help='Preview only')
|
|
parser.add_argument('--limit', type=int, help='Limit for testing')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.customers and not args.orders:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
print("=" * 70)
|
|
print("COMPLETE WOOCOMMERCE TO SALEOR MIGRATION")
|
|
print("=" * 70)
|
|
print()
|
|
print("Scope:")
|
|
print(" ✓ ALL 4,886 WordPress users (including 3,700+ prospects)")
|
|
print(" ✓ ALL customers from order billing emails")
|
|
print(" ✓ ALL 1,786 orders")
|
|
print(" ✓ Pending/Processing = FULFILLED (COD collected)")
|
|
print(" ✓ Cancelled = CANCELLED")
|
|
print()
|
|
|
|
print("Connecting to databases...")
|
|
try:
|
|
exporter = CompleteExporter(WP_DB_CONFIG)
|
|
importer = CompleteImporter(SALEOR_DB_CONFIG)
|
|
print("Connected!\n")
|
|
except Exception as e:
|
|
print(f"Failed: {e}")
|
|
sys.exit(1)
|
|
|
|
# Migrate customers first
|
|
if args.customers:
|
|
print("Fetching ALL customers (WP users + order emails)...")
|
|
customers = exporter.get_all_customers()
|
|
|
|
if args.limit:
|
|
customers = dict(list(customers.items())[:args.limit])
|
|
|
|
print(f"Found {len(customers)} unique customers\n")
|
|
|
|
# Segment breakdown
|
|
segments = defaultdict(int)
|
|
sources = defaultdict(int)
|
|
for c in customers.values():
|
|
segments[c.segment] += 1
|
|
sources[c.source] += 1
|
|
|
|
print("Sources:")
|
|
for src, count in sorted(sources.items()):
|
|
print(f" {src}: {count}")
|
|
print()
|
|
|
|
print("Segments:")
|
|
for seg, count in sorted(segments.items(), key=lambda x: -x[1]):
|
|
print(f" {seg}: {count}")
|
|
print()
|
|
|
|
print("Creating customers...")
|
|
for i, (email, customer) in enumerate(customers.items(), 1):
|
|
print(f"[{i}/{len(customers)}]", end=" ")
|
|
try:
|
|
importer.import_customer(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 ALL orders...")
|
|
orders = exporter.get_all_orders(limit=args.limit)
|
|
print(f"Found {len(orders)} orders\n")
|
|
|
|
paid = sum(1 for o in orders if o.is_paid)
|
|
cancelled = len(orders) - paid
|
|
print(f"Breakdown: {paid} fulfilled, {cancelled} cancelled\n")
|
|
|
|
print("Migrating orders...")
|
|
for i, order in enumerate(orders, 1):
|
|
print(f"[{i}/{len(orders)}]", end=" ")
|
|
try:
|
|
importer.import_order(order, 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")
|
|
|
|
# Summary
|
|
print("=" * 70)
|
|
print("SUMMARY")
|
|
print("=" * 70)
|
|
print(f"Customers: {len(importer.email_to_user_id)}")
|
|
|
|
if args.customers:
|
|
print("\nBy segment:")
|
|
with importer.conn.cursor() as cursor:
|
|
cursor.execute("""
|
|
SELECT segment, COUNT(*) as count, SUM(total_spent) as revenue
|
|
FROM wc_complete_mapping
|
|
GROUP BY segment
|
|
ORDER BY count DESC
|
|
""")
|
|
for row in cursor.fetchall():
|
|
print(f" {row[0]}: {row[1]} ({row[2] or 0:,.0f} RSD)")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|