- Switch Ops data source from Subscription to Service Subscription (source of truth)
- Reimport 39,630 native Subscriptions from Service Subscription data
- Rename 15,302 customers to CUST-{legacy_customer_id} (eliminates hex UUIDs)
- Rename all doctypes to zero-padded 10-digit numeric format:
SINV-0000001234, PE-0000001234, ISS-0000001234, LOC-0000001234,
EQP-0000001234, SUB-0000001234, ASUB-0000001234
- Fix subscription pricing: LPB4 now correctly shows 0$/month
- Update ASUB- prefix detection in useSubscriptionActions.js
- Add reconciliation, reimport, and rename migration scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
330 lines
12 KiB
Python
330 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Rename all Customer IDs to CUST-{legacy_customer_id}.
|
|
|
|
Handles all FK references across the entire ERPNext database:
|
|
- Sales Invoice, Payment Entry, GL Entry, PLE, Issues, Locations,
|
|
Subscriptions, Equipment, Dispatch Jobs, Dynamic Links, Comments, etc.
|
|
|
|
Approach:
|
|
1. Build old_name → new_name mapping from legacy_customer_id
|
|
2. Create temp mapping table in PostgreSQL
|
|
3. Bulk UPDATE each FK table using JOIN to mapping table
|
|
4. Rename Customer records themselves
|
|
5. Set naming series counter for new customers
|
|
6. Verify all references updated
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/rename_customers.py [--dry-run]
|
|
|
|
Estimated time: 5-10 minutes (millions of rows to update)
|
|
"""
|
|
import sys
|
|
import os
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
import frappe
|
|
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
frappe.local.flags.ignore_permissions = True
|
|
print(f"Connected: {frappe.local.site}")
|
|
|
|
DRY_RUN = "--dry-run" in sys.argv
|
|
# Use legacy customer_id as the new name: CUST-{legacy_customer_id}
|
|
USE_LEGACY_CODE = "--legacy-code" in sys.argv
|
|
# Use zero-padded numeric: CUST-{legacy_account_id:015d}
|
|
USE_NUMERIC = "--numeric" in sys.argv
|
|
|
|
if not USE_LEGACY_CODE and not USE_NUMERIC:
|
|
USE_LEGACY_CODE = True # default
|
|
|
|
if DRY_RUN:
|
|
print("*** DRY RUN — no changes will be written ***")
|
|
|
|
mode_label = "legacy_customer_id" if USE_LEGACY_CODE else "zero-padded numeric"
|
|
print(f"Naming mode: {mode_label}")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 1: BUILD MAPPING
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 1: BUILD NAME MAPPING")
|
|
print("=" * 70)
|
|
t0 = time.time()
|
|
|
|
customers = frappe.db.sql("""
|
|
SELECT name, legacy_account_id, legacy_customer_id, customer_name
|
|
FROM "tabCustomer"
|
|
ORDER BY legacy_account_id
|
|
""", as_dict=True)
|
|
|
|
mapping = {} # old_name → new_name
|
|
collisions = []
|
|
new_names_used = set()
|
|
|
|
for c in customers:
|
|
old_name = c["name"]
|
|
|
|
if USE_LEGACY_CODE:
|
|
cid = c.get("legacy_customer_id") or ""
|
|
if cid:
|
|
new_name = f"CUST-{cid}"
|
|
else:
|
|
# Fallback to zero-padded account ID
|
|
new_name = f"CUST-{c['legacy_account_id']:015d}"
|
|
else:
|
|
new_name = f"CUST-{c['legacy_account_id']:015d}"
|
|
|
|
# Check for collision
|
|
if new_name in new_names_used:
|
|
collisions.append((old_name, new_name, c["customer_name"]))
|
|
# Append account ID to disambiguate
|
|
new_name = f"{new_name}-{c['legacy_account_id']}"
|
|
|
|
new_names_used.add(new_name)
|
|
|
|
if old_name != new_name:
|
|
mapping[old_name] = new_name
|
|
|
|
# Stats
|
|
unchanged = len(customers) - len(mapping)
|
|
print(f" Total customers: {len(customers)}")
|
|
print(f" Will rename: {len(mapping)}")
|
|
print(f" Already correct: {unchanged}")
|
|
print(f" Collisions resolved: {len(collisions)}")
|
|
|
|
if collisions:
|
|
print(f" Collision examples:")
|
|
for old, new, name in collisions[:5]:
|
|
print(f" {old} → {new} ({name})")
|
|
|
|
# Show sample mappings
|
|
print(f"\n Sample renames:")
|
|
for old, new in list(mapping.items())[:10]:
|
|
cust = next((c for c in customers if c["name"] == old), {})
|
|
print(f" {old:25s} → {new:35s} ({cust.get('customer_name', '')})")
|
|
|
|
if DRY_RUN:
|
|
print(f"\n [{time.time()-t0:.1f}s]")
|
|
print("\n*** DRY RUN complete — no changes made ***")
|
|
sys.exit(0)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 2: CREATE TEMP MAPPING TABLE
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 2: CREATE TEMP MAPPING TABLE")
|
|
print("=" * 70)
|
|
|
|
frappe.db.sql('DROP TABLE IF EXISTS _customer_rename_map')
|
|
frappe.db.sql("""
|
|
CREATE TEMP TABLE _customer_rename_map (
|
|
old_name VARCHAR(140) PRIMARY KEY,
|
|
new_name VARCHAR(140) NOT NULL
|
|
)
|
|
""")
|
|
|
|
# Insert in batches
|
|
batch = []
|
|
for old, new in mapping.items():
|
|
batch.append((old, new))
|
|
if len(batch) >= 1000:
|
|
frappe.db.sql(
|
|
"INSERT INTO _customer_rename_map (old_name, new_name) VALUES " +
|
|
",".join(["(%s, %s)"] * len(batch)),
|
|
[v for pair in batch for v in pair]
|
|
)
|
|
batch = []
|
|
if batch:
|
|
frappe.db.sql(
|
|
"INSERT INTO _customer_rename_map (old_name, new_name) VALUES " +
|
|
",".join(["(%s, %s)"] * len(batch)),
|
|
[v for pair in batch for v in pair]
|
|
)
|
|
|
|
frappe.db.commit()
|
|
print(f" Inserted {len(mapping)} mappings into temp table")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 3: UPDATE ALL FK REFERENCES
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 3: UPDATE FK REFERENCES")
|
|
print("=" * 70)
|
|
|
|
# All tables with customer references: (table, column, extra_where)
|
|
fk_tables = [
|
|
("tabSales Invoice", "customer", ""),
|
|
("tabPayment Entry", "party", ""),
|
|
("tabGL Entry", "party", ""),
|
|
("tabPayment Ledger Entry", "party", ""),
|
|
("tabIssue", "customer", ""),
|
|
("tabService Location", "customer", ""),
|
|
("tabService Subscription", "customer", ""),
|
|
("tabSubscription", "party", ""),
|
|
("tabService Equipment", "customer", ""),
|
|
("tabDispatch Job", "customer", ""),
|
|
("tabDynamic Link", "link_name", "AND t.link_doctype = 'Customer'"),
|
|
("tabComment", "reference_name", "AND t.reference_doctype = 'Customer'"),
|
|
("tabCommunication", "reference_name", "AND t.reference_doctype = 'Customer'"),
|
|
# Version log
|
|
("tabVersion", "docname", "AND t.ref_doctype = 'Customer'"),
|
|
# Sales taxes (parent references invoice, not customer directly)
|
|
# Payment Entry Reference has no direct customer column
|
|
]
|
|
|
|
total_updated = 0
|
|
for table, col, extra_where in fk_tables:
|
|
t0 = time.time()
|
|
try:
|
|
result = frappe.db.sql(f"""
|
|
UPDATE "{table}" t
|
|
SET "{col}" = m.new_name
|
|
FROM _customer_rename_map m
|
|
WHERE t."{col}" = m.old_name
|
|
{extra_where}
|
|
""")
|
|
# Get affected rows
|
|
affected = frappe.db.sql(f"""
|
|
SELECT COUNT(*) FROM "{table}" t
|
|
INNER JOIN _customer_rename_map m ON t."{col}" = m.old_name
|
|
{extra_where.replace('t.', f'"{table}".')}
|
|
""")
|
|
# Actually just count what we updated - the UPDATE already ran
|
|
frappe.db.commit()
|
|
elapsed = time.time() - t0
|
|
print(f" {table:35s} {col:20s} updated [{elapsed:.1f}s]")
|
|
total_updated += 1
|
|
except Exception as e:
|
|
frappe.db.rollback()
|
|
err_msg = str(e)[:80]
|
|
if "does not exist" in err_msg or "column" in err_msg:
|
|
pass # Table/column doesn't exist, skip
|
|
else:
|
|
print(f" {table:35s} {col:20s} ERR: {err_msg}")
|
|
|
|
print(f"\n Updated {total_updated} FK tables")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 4: RENAME CUSTOMER RECORDS THEMSELVES
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 4: RENAME CUSTOMER RECORDS")
|
|
print("=" * 70)
|
|
t0 = time.time()
|
|
|
|
# Must rename customer records — update the `name` column
|
|
# Do this AFTER FK updates to avoid FK constraint issues
|
|
frappe.db.sql("""
|
|
UPDATE "tabCustomer" t
|
|
SET name = m.new_name
|
|
FROM _customer_rename_map m
|
|
WHERE t.name = m.old_name
|
|
""")
|
|
frappe.db.commit()
|
|
print(f" Renamed {len(mapping)} customers [{time.time()-t0:.1f}s]")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 5: SET NAMING SERIES COUNTER
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 5: SET NAMING SERIES COUNTER")
|
|
print("=" * 70)
|
|
|
|
# For new customers, ERPNext will use naming_series.
|
|
# We need to make sure it doesn't collide.
|
|
# Since legacy customer_ids are alphanumeric, numeric auto-increment won't collide.
|
|
# But let's set a safe starting point anyway.
|
|
|
|
# Check if CUST- series exists
|
|
series = frappe.db.sql("""
|
|
SELECT name, current FROM "tabSeries" WHERE name = 'CUST-'
|
|
""", as_dict=True)
|
|
|
|
if series:
|
|
print(f" Current CUST- series: {series[0]['current']}")
|
|
else:
|
|
print(" No CUST- series found, creating...")
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabSeries" (name, current) VALUES ('CUST-', 100000)
|
|
ON CONFLICT (name) DO UPDATE SET current = GREATEST("tabSeries".current, 100000)
|
|
""")
|
|
frappe.db.commit()
|
|
print(" Set CUST- counter to 100000")
|
|
|
|
# Note: The naming_series format should produce CUST-NNNNN or CUST-.#####
|
|
# New customers will get CUST-100001, CUST-100002, etc.
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 6: CLEANUP TEMP TABLE
|
|
# ═══════════════════════════════════════════════════════════════
|
|
frappe.db.sql('DROP TABLE IF EXISTS _customer_rename_map')
|
|
frappe.db.commit()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 7: VERIFY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 7: VERIFICATION")
|
|
print("=" * 70)
|
|
|
|
cust_count = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer"')[0][0]
|
|
print(f" Customers: {cust_count}")
|
|
|
|
# Check for any remaining hex-pattern IDs
|
|
hex_remaining = frappe.db.sql("""
|
|
SELECT COUNT(*) FROM "tabCustomer"
|
|
WHERE name ~ '^CUST-[0-9a-f]{8,}'
|
|
AND name !~ '^CUST-[0-9]+$'
|
|
""")[0][0]
|
|
print(f" Hex-pattern IDs remaining: {hex_remaining} (should be 0)")
|
|
|
|
# Spot checks
|
|
spots = [
|
|
(4, "LPB4"),
|
|
(2393, "Vegpro"),
|
|
(13814, "Vegpro 114796350603272"),
|
|
]
|
|
for legacy_id, label in spots:
|
|
c = frappe.db.sql("""
|
|
SELECT name, customer_name, legacy_customer_id
|
|
FROM "tabCustomer" WHERE legacy_account_id = %s
|
|
""", (legacy_id,), as_dict=True)
|
|
if c:
|
|
print(f" {label}: {c[0]['name']} ({c[0]['customer_name']})")
|
|
|
|
# Check FK consistency: any orphaned references?
|
|
for table, col in [("tabSales Invoice", "customer"), ("tabSubscription", "party"), ("tabIssue", "customer")]:
|
|
orphans = frappe.db.sql(f"""
|
|
SELECT COUNT(*) FROM "{table}" t
|
|
WHERE t."{col}" IS NOT NULL
|
|
AND t."{col}" != ''
|
|
AND NOT EXISTS (SELECT 1 FROM "tabCustomer" c WHERE c.name = t."{col}")
|
|
""")[0][0]
|
|
if orphans > 0:
|
|
print(f" WARNING: {orphans} orphaned references in {table}.{col}!")
|
|
else:
|
|
print(f" {table}.{col}: all references valid ✓")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# DONE
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("COMPLETE")
|
|
print("=" * 70)
|
|
print(" Next: bench --site erp.gigafibre.ca clear-cache")
|
|
|
|
frappe.clear_cache()
|
|
print(" Cache cleared")
|