feat(saleor): Phase 1 - GraphQL Client Setup

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

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

View File

@@ -0,0 +1,304 @@
# Email Reactivation Campaign Strategy
## Post-Migration Marketing Plan
### Customer Segments (4,886 Total)
| Segment | Count | Definition | Strategy |
|---------|-------|------------|----------|
| **VIP_CUSTOMER** | ~200 | 3+ completed orders | Loyalty rewards, early access, referral program |
| **ACTIVE_CUSTOMER** | ~972 | 1-2 completed orders | Cross-sell, subscription, reviews |
| **CART_ABANDONER** | ~1,086 | Pending/processing orders | Recovery sequence, discount incentive |
| **PROSPECT** | ~2,628 | Registered, never ordered | Welcome series, education, first-order discount |
---
## Campaign 1: Cart Abandoner Recovery
**Target:** 1,086 users with pending/processing orders
### Email Sequence
#### Email 1: Immediate (0 hours)
```
Subject: Zaboravili ste nešto u korpi 👀
Pozdrav [First Name],
Primijetili smo da ste ostavili artikle u korpi za kupovinu:
[Product Name] - [Price] RSD
Poštarina je BESPLATNA za narudžbine preko 3.000 RSD.
[DOVRŠI KUPOVINU]
Pitanja? Odgovorite na ovaj email.
---
Team Manoon
```
#### Email 2: 24 hours
```
Subject: Još uvijek čekamo vas 🛒
[First Name],
Vaša korpa još uvijek čeka:
[Product Image]
[Product Name]
Ostalo je još samo par komada na zalihi.
[DOVRŠI KUPOVINU]
```
#### Email 3: 72 hours (Final)
```
Subject: Posebna ponuda samo za vas 🎁
[First Name],
Vidimo da ste zainteresovani za naše proizvode.
Koristite kod ZAVRSI10 za 10% popusta na vašu narudžbinu.
Važi naredna 24 sata.
[DOVRŠI KUPOVINU]
```
---
## Campaign 2: Prospect Activation
**Target:** 2,628 registered users who never ordered
### Email Sequence
#### Email 1: Welcome (Day 0)
```
Subject: Dobrodošli u Manoon porodicu ✨
Zdravo [First Name],
Hvala što ste se prijavili! Očekuje vas:
✓ 100% prirodna kozmetika
✓ Vidljivi rezultati za 30 dana
✓ Besplatna dostava preko 3.000 RSD
Kao dobrodošlicu, imate 15% popusta na prvu kupovinu.
Kod: DOBRODOSLI15
[PREGLEDAJ PROIZVODE]
---
Team Manoon
```
#### Email 2: Education (Day 3)
```
Subject: Kako izgleda 30-dnevna transformacija?
[First Name],
Pogledajte neverovatne rezultate naših kupaca:
[Before/After Image Gallery]
💬 "Nakon 3 nedelje primetila sam ogromnu razliku"
- Marija, Beograd
[POGLEDAJ PRIČE]
```
#### Email 3: Social Proof (Day 7)
```
Subject: Više od 1.000 zadovoljnih kupaca
[First Name],
Naši kupci vole:
⭐⭐⭐⭐⭐ "Najbolji serum koji sam koristio"
⭐⭐⭐⭐⭐ "Kosa mi je znatno jača"
⭐⭐⭐⭐⭐ "Konačno prirodni proizvodi koji rade"
[ČITAJ UTISKE]
```
#### Email 4: Urgency (Day 14)
```
Subject: Poslednja prilika: 15% popusta
[First Name],
Vaš kod DOBRODOSLI15 ističe za 48 sati.
Ne propustite priliku da isprobate našu prirodnu kozmetiku sa popustom.
[ISKORISTI POPUST]
```
---
## Campaign 3: Win-Back (Inactive Customers)
**Target:** Active customers who haven't ordered in 6+ months
### Email Sequence
#### Email 1: "We Miss You" (Day 0)
```
Subject: Nedostajete nam, [First Name] 💚
Zdravo [First Name],
Primijetili smo da dugo niste naručivali.
Imamo novo za vas:
🆕 Novi proizvodi
🎁 Specijalne ponude
📦 Brža dostava
Želite da vidite šta je novo?
[VIDI NOVITETE]
```
#### Email 2: Incentive (Day 7)
```
Subject: Specijalna ponuda za povratak
[First Name],
Kao znak zahvalnosti za vašu raniju podršku:
20% popusta na sledeću kupovinu
Kod: POVRATAK20
Važi do: [Date]
[KUPI SADA]
```
---
## Campaign 4: VIP Customer Rewards
**Target:** 200 customers with 3+ orders
### Exclusive Perks
1. **Early Access** - New products 48 hours before public
2. **Birthday Gift** - Free product on birthday
3. **Referral Program** - Give 15%, Get 15%
4. **Exclusive Content** - Behind the scenes, beauty tips
#### Email Template
```
Subject: Vi ste naš VIP kupac 🌟
Draga [First Name],
Zahvaljujući vašoj podršci ([X] kupovina), postali ste deo našeg VIP kluba.
Vaše privilegije:
✨ Rani pristup novim proizvodima
🎁 Rođendanski poklon
💰 20% popust na SVAKU kupovinu
👥 Poklonite 15% prijateljima, zaradite 15%
[VIDI VIP PONUDE]
Hvala vam što ste deo Manoon priče.
---
Team Manoon
```
---
## Technical Implementation
### Saleor Setup for Segmentation
```python
# Add custom metadata to users during migration
metadata = {
"segment": "CART_ABANDONER", # or VIP_CUSTOMER, ACTIVE_CUSTOMER, PROSPECT
"wp_user_id": 12345,
"order_count": 2,
"completed_orders": 1,
"total_spent": 15000.00,
"first_order_date": "2023-01-15",
"registration_date": "2022-11-20"
}
```
### Integration Options
#### Option 1: Saleor Webhooks + n8n + MailerLite/Mailchimp
```
Saleor User Created → n8n → Add to Email List → Trigger Sequence
```
#### Option 2: Direct SQL Queries for Export
```sql
-- Export PROSPECTS for welcome campaign
SELECT email, first_name, metadata->>'registration_date' as date
FROM account_user
WHERE metadata->>'segment' = 'PROSPECT';
-- Export CART_ABANDONERS
SELECT email, first_name, metadata->>'order_count' as orders
FROM account_user
WHERE metadata->>'segment' = 'CART_ABANDONER';
```
#### Option 3: Mautic (already installed on your cluster)
- Import segmented lists
- Create campaigns per segment
- Track opens, clicks, conversions
---
## Campaign Calendar
| Week | Campaign | Target | Emails |
|------|----------|--------|--------|
| 1 | Cart Recovery | 1,086 abandoners | 3 emails |
| 2 | Prospect Welcome | 2,628 prospects | 4 emails |
| 3 | Win-Back | Inactive customers | 2 emails |
| 4 | VIP Launch | 200 VIPs | 1 email + setup |
| Ongoing | Nurture | All segments | Monthly newsletter |
---
## Success Metrics
| Metric | Target |
|--------|--------|
| Cart recovery rate | 10-15% |
| Prospect conversion | 5-8% |
| Win-back rate | 3-5% |
| VIP referral rate | 20% |
| Overall email open rate | >25% |
| Click-through rate | >3% |
---
## Next Steps
1. **Migrate data** using `migrate_all_users_and_orders.py`
2. **Set up email platform** (MailerLite, Mailchimp, or Mautic)
3. **Create email templates** in your chosen platform
4. **Import segmented lists** from Saleor
5. **Launch campaigns** in sequence
6. **Track results** and optimize

View File

@@ -0,0 +1,852 @@
#!/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()

View File

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

785
scripts/migrate_complete.py Normal file
View File

@@ -0,0 +1,785 @@
#!/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()

View 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()

View File

@@ -0,0 +1,201 @@
-- =====================================================
-- WooCommerce GUEST Checkout to Saleor Migration
-- =====================================================
-- For stores without customer accounts - all data is in orders
-- Since there are no customer accounts, we create users from order data
-- Strategy: Create a Saleor user for each unique email in orders
-- Step 1: Create mapping table for email-based customers
CREATE TABLE IF NOT EXISTS wc_guest_customer_mapping (
email VARCHAR(255) PRIMARY KEY,
saleor_user_id UUID,
first_name VARCHAR(255),
last_name VARCHAR(255),
phone VARCHAR(255),
order_count INTEGER DEFAULT 0,
total_spent DECIMAL(12,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- Step 2: Export unique customers from orders (no wp_users needed!)
-- Run this on WordPress/MariaDB:
/*
SELECT DISTINCT
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 order_count,
SUM(CAST(meta_total.meta_value AS DECIMAL(12,2))) as total_spent
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
ORDER BY order_count DESC;
*/
-- Step 3: Insert guest customers into Saleor
-- For each unique email, create a user account
/*
WITH new_guest_user AS (
INSERT INTO account_user (
id, email, first_name, last_name,
is_staff, is_active, date_joined,
last_login, password
) VALUES (
gen_random_uuid(),
'customer@example.com', -- from order billing_email
'John', -- from order billing_first_name
'Doe', -- from order billing_last_name
false,
true,
NOW(), -- use first order date if available
NULL,
'!' -- unusable password - customer must set via password reset
)
RETURNING id, email
)
INSERT INTO wc_guest_customer_mapping (email, saleor_user_id, first_name, last_name)
SELECT email, id, 'John', 'Doe' FROM new_guest_user;
*/
-- Step 4: Create addresses from most recent order per customer
-- Get the most recent order for each email to extract address
/*
WITH latest_orders AS (
SELECT DISTINCT ON (meta_email.meta_value)
meta_email.meta_value as email,
p.ID as order_id,
p.post_date as 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'
WHERE p.post_type = 'shop_order'
ORDER BY meta_email.meta_value, p.post_date DESC
),
address_data AS (
SELECT
lo.email,
MAX(CASE WHEN pm.meta_key = '_billing_first_name' THEN pm.meta_value END) as bill_first,
MAX(CASE WHEN pm.meta_key = '_billing_last_name' THEN pm.meta_value END) as bill_last,
MAX(CASE WHEN pm.meta_key = '_billing_company' THEN pm.meta_value END) as bill_company,
MAX(CASE WHEN pm.meta_key = '_billing_address_1' THEN pm.meta_value END) as bill_addr1,
MAX(CASE WHEN pm.meta_key = '_billing_address_2' THEN pm.meta_value END) as bill_addr2,
MAX(CASE WHEN pm.meta_key = '_billing_city' THEN pm.meta_value END) as bill_city,
MAX(CASE WHEN pm.meta_key = '_billing_postcode' THEN pm.meta_value END) as bill_postcode,
MAX(CASE WHEN pm.meta_key = '_billing_country' THEN pm.meta_value END) as bill_country,
MAX(CASE WHEN pm.meta_key = '_billing_phone' THEN pm.meta_value END) as bill_phone,
MAX(CASE WHEN pm.meta_key = '_shipping_first_name' THEN pm.meta_value END) as ship_first,
MAX(CASE WHEN pm.meta_key = '_shipping_last_name' THEN pm.meta_value END) as ship_last,
MAX(CASE WHEN pm.meta_key = '_shipping_company' THEN pm.meta_value END) as ship_company,
MAX(CASE WHEN pm.meta_key = '_shipping_address_1' THEN pm.meta_value END) as ship_addr1,
MAX(CASE WHEN pm.meta_key = '_shipping_address_2' THEN pm.meta_value END) as ship_addr2,
MAX(CASE WHEN pm.meta_key = '_shipping_city' THEN pm.meta_value END) as ship_city,
MAX(CASE WHEN pm.meta_key = '_shipping_postcode' THEN pm.meta_value END) as ship_postcode,
MAX(CASE WHEN pm.meta_key = '_shipping_country' THEN pm.meta_value END) as ship_country
FROM latest_orders lo
JOIN wp_postmeta pm ON lo.order_id = pm.post_id
GROUP BY lo.email
)
-- Insert billing address and link to user
INSERT INTO account_address (id, first_name, last_name, company_name,
street_address_1, street_address_2, city, postal_code, country, phone)
SELECT
gen_random_uuid(),
bill_first, bill_last, COALESCE(bill_company, ''),
bill_addr1, COALESCE(bill_addr2, ''), bill_city,
bill_postcode, COALESCE(bill_country, 'RS'), COALESCE(bill_phone, '')
FROM address_data ad
JOIN wc_guest_customer_mapping cm ON ad.email = cm.email
WHERE cm.saleor_user_id IS NOT NULL
RETURNING id, (SELECT email FROM wc_guest_customer_mapping WHERE saleor_user_id =
(SELECT id FROM account_user WHERE id = account_address.id)); -- This needs adjustment
-- Then link addresses to users via account_user_addresses
*/
-- Alternative simpler approach: Insert order with addresses inline (no separate customer record)
-- Saleor supports orders without user accounts (guest orders)
-- =====================================================
-- SIMPLIFIED: Orders Only (No Customer Accounts)
-- =====================================================
-- If you don't want to create customer accounts at all,
-- just migrate orders as guest orders with email addresses
/*
INSERT INTO order_order (
id, created_at, updated_at, status,
user_email, -- Store email here (no user_id)
user_id, -- NULL for guest orders
currency, total_gross_amount, total_net_amount,
shipping_price_gross_amount, shipping_price_net_amount,
shipping_method_name, channel_id,
billing_address, -- JSON with full address
shipping_address, -- JSON with full address
metadata, origin,
should_refresh_prices, tax_exemption,
discount_amount, display_gross_prices,
customer_note
) VALUES (
gen_random_uuid(),
'2024-01-15 10:30:00'::timestamp,
'2024-01-15 10:30:00'::timestamp,
'FULFILLED',
'guest@example.com', -- Customer email from order
NULL, -- No user account (guest order)
'RSD',
11500.00,
10000.00,
500.00,
500.00,
'Flat Rate',
(SELECT id FROM channel_channel WHERE slug = 'default-channel'),
'{
"first_name": "John",
"last_name": "Doe",
"street_address_1": "Kneza Milosa 10",
"city": "Belgrade",
"postal_code": "11000",
"country": "RS",
"phone": "+38164123456"
}'::jsonb,
'{
"first_name": "John",
"last_name": "Doe",
"street_address_1": "Kneza Milosa 10",
"city": "Belgrade",
"postal_code": "11000",
"country": "RS",
"phone": "+38164123456"
}'::jsonb,
'{"woo_order_id": "12345", "guest_checkout": true}'::jsonb,
'BULK_CREATE',
false,
false,
0.00,
true,
''
);
*/
-- =====================================================
-- RECOMMENDED APPROACH: Hybrid
-- =====================================================
-- 1. Create lightweight user accounts from unique emails
-- 2. Link all orders to these accounts
-- 3. Customers can claim accounts via password reset
-- Benefits:
-- - Order history tied to email
-- - Customers can "activate" their account later
-- - Better analytics (LTV per customer)
-- - Future marketing (targeted emails)