- 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
853 lines
37 KiB
Python
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()
|