Files
manoon-headless/scripts/migrate_all_users_and_orders.py
Unchained 7b94537670 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
2026-03-21 12:36:21 +02:00

853 lines
37 KiB
Python

#!/usr/bin/env python3
"""
WooCommerce COMPLETE User & Order Migration to Saleor
=======================================================
ASSUMPTION: For COD stores, ALL orders = fulfilled (paid) EXCEPT cancelled
In early WooCommerce stores, order status tracking was inconsistent, but
if an order was not cancelled, the COD payment was collected.
This script treats:
- wc-completed, wc-pending, wc-processing, wc-on-hold = FULFILLED (PAID)
- wc-cancelled, wc-refunded, wc-failed = CANCELLED (NOT PAID)
Migrates ALL WordPress users (not just customers with orders):
- Customers with orders (1,172) → Active customers
- Users without orders (3,714) → Leads/Prospects for reactivation
Segmentation Strategy:
- VIP: 4+ orders
- Repeat: 2-3 orders
- One-time: 1 order
- Prospect: 0 orders
Use cases after migration:
1. Email reactivation campaigns for prospects
2. Win-back campaigns for inactive customers
3. Welcome series for new registrations
4. Segmented marketing based on activity
"""
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, field
from collections import defaultdict
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 - SIMPLIFIED
# ALL orders are treated as FULFILLED (paid) EXCEPT cancelled
# For COD stores: if not cancelled, payment was collected
ORDER_STATUS_MAP = {
'wc-pending': 'FULFILLED', # All treated as completed
'wc-processing': 'FULFILLED',
'wc-on-hold': 'FULFILLED',
'wc-completed': 'FULFILLED',
'wc-cancelled': 'CANCELED', # Only cancelled = not paid
'wc-refunded': 'CANCELED', # Refunded = not paid
'wc-failed': 'CANCELED',
}
# Statuses that indicate payment was collected (for COD)
# Everything EXCEPT cancelled/refunded/failed
PAID_STATUSES = ['wc-completed', 'wc-pending', 'wc-processing', 'wc-on-hold']
@dataclass
class WPUser:
"""WordPress user with activity tracking"""
wp_user_id: int
email: str
first_name: str
last_name: str
date_registered: datetime
phone: Optional[str] = None
billing_address: Optional[Dict] = None
shipping_address: Optional[Dict] = None
# Activity tracking - UPDATED to count pending/processing as paid
order_count: int = 0
paid_orders: int = 0 # completed + pending + processing
cancelled_orders: int = 0
total_spent: float = 0.0
last_order_date: Optional[datetime] = None
first_order_date: Optional[datetime] = None
# Segmentation
@property
def segment(self) -> str:
"""Determine customer segment for marketing"""
# Simplified: all non-cancelled orders = paid
if self.paid_orders >= 4:
return "VIP_CUSTOMER"
elif self.paid_orders >= 2:
return "REPEAT_CUSTOMER"
elif self.paid_orders == 1:
return "ONE_TIME"
else:
return "PROSPECT"
@property
def ltv(self) -> float:
"""Lifetime value in RSD"""
return self.total_spent
@dataclass
class CODOrder:
"""COD Order - updated to mark pending/processing as paid"""
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 # True for completed, pending, processing
wp_user_id: Optional[int] = None # Link to WordPress user if registered
class CompleteExporter:
"""Export ALL WordPress users and orders"""
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_all_users_with_activity(self) -> List[WPUser]:
"""Get ALL WordPress users with their order activity - UPDATED"""
query = """
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,
-- Order activity - count pending/processing as paid
COUNT(DISTINCT p.ID) as order_count,
COUNT(DISTINCT CASE WHEN p.post_status IN ('wc-completed', 'wc-pending', 'wc-processing') THEN p.ID END) as paid_orders,
COUNT(DISTINCT CASE WHEN p.post_status = 'wc-cancelled' THEN p.ID END) as cancelled_orders,
SUM(CASE WHEN p.post_status IN ('wc-completed', 'wc-pending', 'wc-processing') THEN CAST(COALESCE(meta_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_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'
LEFT JOIN wp_postmeta pm ON pm.meta_key = '_customer_user' AND pm.meta_value = u.ID
LEFT JOIN wp_posts p ON p.ID = pm.post_id AND p.post_type = 'shop_order'
LEFT JOIN wp_postmeta meta_total ON p.ID = meta_total.post_id AND meta_total.meta_key = '_order_total'
GROUP BY u.ID, u.user_email, u.user_registered, um_first.meta_value, um_last.meta_value, um_phone.meta_value
ORDER BY u.ID
"""
with self.conn.cursor() as cursor:
cursor.execute(query)
rows = cursor.fetchall()
users = []
for row in rows:
# Get address from most recent order or usermeta
address = self._get_user_address(row['wp_user_id'])
user = WPUser(
wp_user_id=row['wp_user_id'],
email=row['email'],
first_name=row['first_name'] or '',
last_name=row['last_name'] or '',
date_registered=row['date_registered'],
phone=row['phone'],
billing_address=address,
shipping_address=address,
order_count=row['order_count'] or 0,
paid_orders=row['paid_orders'] or 0,
cancelled_orders=row['cancelled_orders'] or 0,
total_spent=float(row['total_spent'] or 0),
first_order_date=row['first_order_date'],
last_order_date=row['last_order_date']
)
users.append(user)
return users
def get_orders(self, limit: Optional[int] = None,
status: Optional[str] = None) -> List[CODOrder]:
"""Fetch orders with user linking"""
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,
meta_customer_id.meta_value as wp_user_id
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'
LEFT JOIN wp_postmeta meta_customer_id ON p.ID = meta_customer_id.post_id AND meta_customer_id.meta_key = '_customer_user'
WHERE p.post_type = 'shop_order'
"""
params = []
if status:
# Handle multiple statuses
statuses = status.split(',')
if len(statuses) == 1:
query += " AND p.post_status = %s"
params.append(status)
else:
placeholders = ','.join(['%s'] * len(statuses))
query += f" AND p.post_status IN ({placeholders})"
params.extend(statuses)
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'])
# UPDATED: Treat pending/processing as paid
is_paid = row['status'] in PAID_STATUSES
wp_user_id = int(row['wp_user_id']) if row['wp_user_id'] else None
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,
wp_user_id=wp_user_id
)
orders.append(order)
return orders
def _get_user_address(self, user_id: int) -> Optional[Dict]:
"""Get address from user's most recent order or usermeta"""
# Try to get from most recent order first
query = """
SELECT
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_postmeta pm_customer
JOIN wp_posts p ON p.ID = pm_customer.post_id AND p.post_type = 'shop_order'
JOIN wp_postmeta pm ON pm.post_id = p.ID
WHERE pm_customer.meta_key = '_customer_user' AND pm_customer.meta_value = %s
ORDER BY p.post_date DESC
LIMIT 1
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (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': 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 '',
}
# Fall back to usermeta
query = """
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_company' THEN meta_value END) as company,
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
"""
with self.conn.cursor() as cursor:
cursor.execute(query, (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': 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 '',
}
return None
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 CompleteImporter:
"""Import all users and orders with segmentation"""
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.wp_id_to_saleor_id: Dict[int, uuid.UUID] = {}
self._ensure_tables()
self._load_mappings()
def _ensure_tables(self):
"""Create mapping and segmentation tables"""
with self.conn.cursor() as cursor:
# User mapping with segmentation data - UPDATED schema
cursor.execute("""
CREATE TABLE IF NOT EXISTS wc_complete_user_mapping (
wp_user_id BIGINT PRIMARY KEY,
saleor_user_id UUID NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
segment VARCHAR(50) NOT NULL,
order_count INTEGER DEFAULT 0,
paid_orders INTEGER DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
first_order_date TIMESTAMP,
last_order_date TIMESTAMP,
date_registered TIMESTAMP,
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,
wp_user_id BIGINT,
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 wp_user_id, saleor_user_id FROM wc_complete_user_mapping")
for row in cursor.fetchall():
self.wp_id_to_saleor_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_user(self, user: WPUser, dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import a WordPress user with segmentation metadata"""
if user.wp_user_id in self.wp_id_to_saleor_id:
return self.wp_id_to_saleor_id[user.wp_user_id]
user_id = uuid.uuid4()
if dry_run:
print(f" [{user.segment}] Would create: {user.email} (Paid orders: {user.paid_orders}, LTV: {user.ltv:.0f} RSD)")
return user_id
with self.conn.cursor() as cursor:
# Create user with segmentation metadata
metadata = {
'wp_user_id': user.wp_user_id,
'segment': user.segment,
'order_count': user.order_count,
'paid_orders': user.paid_orders,
'total_spent': user.total_spent,
'imported_from': 'woocommerce',
'registration_date': user.date_registered.isoformat() if user.date_registered else None
}
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)
""", (
user_id, user.email, user.first_name, user.last_name,
False, True, user.date_registered, '!', json.dumps(metadata)
))
# Create address if available
if user.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, user.billing_address['first_name'], user.billing_address['last_name'],
user.billing_address['company_name'], user.billing_address['street_address_1'],
user.billing_address['street_address_2'], user.billing_address['city'],
user.billing_address['postal_code'], user.billing_address['country'],
user.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))
# Record mapping with segmentation
cursor.execute("""
INSERT INTO wc_complete_user_mapping
(wp_user_id, saleor_user_id, email, segment, order_count,
paid_orders, total_spent, first_order_date, last_order_date, date_registered)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
user.wp_user_id, user_id, user.email, user.segment,
user.order_count, user.paid_orders, user.total_spent,
user.first_order_date, user.last_order_date, user.date_registered
))
self.conn.commit()
self.wp_id_to_saleor_id[user.wp_user_id] = user_id
print(f" [{user.segment}] Created: {user.email} (Paid: {user.paid_orders}, LTV: {user.ltv:.0f} RSD)")
return user_id
def import_order(self, order: CODOrder, dry_run: bool = False) -> Optional[uuid.UUID]:
"""Import an order linked to the user - UPDATED for COD assumption"""
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 ID if this was a registered user
user_id = None
if order.wp_user_id and order.wp_user_id in self.wp_id_to_saleor_id:
user_id = self.wp_id_to_saleor_id[order.wp_user_id]
if dry_run:
paid_marker = "" if order.is_paid else ""
print(f" Order {order.order_number} {paid_marker} (Status: {order.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': order.is_paid,
'original_status': order.status,
'wp_user_id': order.wp_user_id
}),
'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))
# UPDATED: Create payment record for ALL paid orders (completed, pending, processing)
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 (COD collected)
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, wp_user_id, customer_email)
VALUES (%s, %s, %s, %s)
""", (order.wc_order_id, order_id, order.wp_user_id, order.customer_email))
self.conn.commit()
return order_id
def main():
parser = argparse.ArgumentParser(
description='Complete WooCommerce Migration (All Users + Orders) - ASSUMES pending=completed for COD'
)
parser.add_argument('--users', action='store_true', help='Migrate all WordPress users')
parser.add_argument('--orders', action='store_true', help='Migrate all orders')
parser.add_argument('--dry-run', action='store_true', help='Preview only')
parser.add_argument('--limit-users', type=int, help='Limit user count')
parser.add_argument('--limit-orders', type=int, help='Limit order count')
parser.add_argument('--segment', type=str,
choices=['VIP_CUSTOMER', 'REPEAT_CUSTOMER', 'ONE_TIME', 'PROSPECT'],
help='Migrate only specific segment')
parser.add_argument('--status', type=str,
help='Order statuses to migrate (default: all except cancelled)')
args = parser.parse_args()
if not args.users and not args.orders:
parser.print_help()
sys.exit(1)
print("=" * 70)
print("COMPLETE WOOCOMMERCE TO SALEOR MIGRATION")
print("=" * 70)
print()
print("ASSUMPTION: ALL orders = FULFILLED (paid) EXCEPT cancelled")
print("For COD: if not cancelled, payment was collected on delivery.")
print()
print("Statuses treated as PAID:", ', '.join(PAID_STATUSES))
print("=" * 70)
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 users
if args.users:
print("Fetching all WordPress users...")
users = exporter.get_all_users_with_activity()
if args.segment:
users = [u for u in users if u.segment == args.segment]
if args.limit_users:
users = users[:args.limit_users]
print(f"Found {len(users)} users to migrate\n")
# Segment breakdown
segments = defaultdict(int)
for u in users:
segments[u.segment] += 1
print("Segment breakdown:")
for seg, count in sorted(segments.items(), key=lambda x: -x[1]):
print(f" {seg}: {count}")
print()
print("Migrating users...")
for i, user in enumerate(users, 1):
print(f"[{i}/{len(users)}]", end=" ")
try:
importer.import_user(user, dry_run=args.dry_run)
except Exception as e:
print(f"ERROR: {e}")
print(f"\nUser migration {'preview' if args.dry_run else 'complete'}!\n")
# Migrate orders
if args.orders:
print("Fetching orders...")
# Default to ALL statuses except cancelled
if args.status:
status_filter = args.status
else:
# Exclude cancelled by default
status_filter = 'wc-completed,wc-pending,wc-processing,wc-on-hold'
orders = exporter.get_orders(limit=args.limit_orders, status=status_filter)
print(f"Found {len(orders)} orders (statuses: {status_filter})\n")
paid = sum(1 for o in orders if o.is_paid)
print(f"Breakdown: {paid} fulfilled (paid), {len(orders)-paid} cancelled\n")
print("Migrating orders...")
for i, order in enumerate(orders, 1):
marker = "" if order.is_paid else ""
print(f"[{i}/{len(orders)}] {order.order_number} {marker}", end=" ")
try:
importer.import_order(order, dry_run=args.dry_run)
print()
except Exception as e:
print(f"ERROR: {e}")
print(f"\nOrder migration {'preview' if args.dry_run else 'complete'}!\n")
# Summary
print("=" * 70)
print("MIGRATION SUMMARY")
print("=" * 70)
print(f"Users migrated: {len(importer.wp_id_to_saleor_id)}")
if args.users:
print("\nBy segment:")
with importer.conn.cursor() as cursor:
cursor.execute("""
SELECT segment, COUNT(*) as count
FROM wc_complete_user_mapping
GROUP BY segment
ORDER BY count DESC
""")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]}")
if __name__ == '__main__':
main()