- InlineField component + useInlineEdit composable for Odoo-style dblclick editing - Client search by name, account ID, and legacy_customer_id (or_filters) - SMS/Email notification panel on ContactCard via n8n webhooks - Ticket reply thread via Communication docs - All migration scripts (51 files) now tracked - Client portal and field tech app added to monorepo - README rewritten with full feature list, migration summary, architecture - CHANGELOG updated with all recent work - ROADMAP updated with current completion status - Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN) - .gitignore updated (docker/, .claude/, exports/, .quasar/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
9.8 KiB
Python
273 lines
9.8 KiB
Python
"""
|
|
Rename hex-based document IDs to human-readable names:
|
|
- Customer: CUST-{hex} → CUST-{legacy_account_id}
|
|
- Service Location: LOC-{hex} → "{address_line}, {city}" or LOC-{legacy_delivery_id}
|
|
- Service Equipment: EQ-{hex} → EQ-{legacy_device_id}
|
|
|
|
Uses direct SQL for speed (frappe.rename_doc is too slow for 15k+ records).
|
|
Updates all foreign key references.
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/rename_to_readable_ids.py
|
|
"""
|
|
import frappe
|
|
import os
|
|
import time
|
|
import re
|
|
from datetime import datetime
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
frappe.local.flags.ignore_permissions = True
|
|
print("Connected:", frappe.local.site)
|
|
|
|
T_TOTAL = time.time()
|
|
|
|
def batch_rename(table, old_to_new, ref_tables, label):
|
|
"""Rename documents and update all foreign key references."""
|
|
if not old_to_new:
|
|
print(" Nothing to rename for {}".format(label))
|
|
return
|
|
|
|
print(" Renaming {} {} records...".format(len(old_to_new), label))
|
|
t0 = time.time()
|
|
|
|
# Build temp mapping table for efficient bulk UPDATE
|
|
# Process in batches to avoid memory issues
|
|
batch_size = 2000
|
|
items = list(old_to_new.items())
|
|
|
|
for batch_start in range(0, len(items), batch_size):
|
|
batch = items[batch_start:batch_start + batch_size]
|
|
|
|
# Update main table name
|
|
for old_name, new_name in batch:
|
|
frappe.db.sql(
|
|
'UPDATE "{}" SET name = %s WHERE name = %s'.format(table),
|
|
(new_name, old_name)
|
|
)
|
|
|
|
# Update all foreign key references
|
|
for ref_table, ref_col in ref_tables:
|
|
for old_name, new_name in batch:
|
|
frappe.db.sql(
|
|
'UPDATE "{}" SET {} = %s WHERE {} = %s'.format(ref_table, ref_col, ref_col),
|
|
(new_name, old_name)
|
|
)
|
|
|
|
frappe.db.commit()
|
|
done = min(batch_start + batch_size, len(items))
|
|
print(" {}/{}...".format(done, len(items)))
|
|
|
|
elapsed = time.time() - t0
|
|
print(" Done {} in {:.1f}s".format(label, elapsed))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 1: RENAME CUSTOMERS
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 1: RENAME CUSTOMERS")
|
|
print("="*60)
|
|
|
|
customers = frappe.db.sql("""
|
|
SELECT name, legacy_account_id FROM "tabCustomer"
|
|
WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0
|
|
ORDER BY legacy_account_id
|
|
""", as_dict=True)
|
|
|
|
cust_rename = {}
|
|
seen_cust = set()
|
|
for c in customers:
|
|
new_name = "CUST-{}".format(c["legacy_account_id"])
|
|
if new_name == c["name"]:
|
|
continue # already correct
|
|
if new_name in seen_cust:
|
|
new_name = "CUST-{}-b".format(c["legacy_account_id"])
|
|
seen_cust.add(new_name)
|
|
cust_rename[c["name"]] = new_name
|
|
|
|
print("Customers to rename: {} (of {})".format(len(cust_rename), len(customers)))
|
|
|
|
# All tables with a 'customer' Link field pointing to Customer
|
|
CUST_REFS = [
|
|
("tabService Location", "customer"),
|
|
("tabService Subscription", "customer"),
|
|
("tabService Equipment", "customer"),
|
|
("tabDispatch Job", "customer"),
|
|
("tabIssue", "customer"),
|
|
("tabSales Invoice", "customer"),
|
|
("tabSales Order", "customer"),
|
|
("tabDelivery Note", "customer"),
|
|
("tabSerial No", "customer"),
|
|
("tabProject", "customer"),
|
|
("tabWarranty Claim", "customer"),
|
|
("tabMaintenance Visit", "customer"),
|
|
("tabMaintenance Schedule", "customer"),
|
|
("tabLoyalty Point Entry", "customer"),
|
|
("tabPOS Invoice", "customer"),
|
|
("tabPOS Invoice Reference", "customer"),
|
|
("tabMaterial Request", "customer"),
|
|
("tabTimesheet", "customer"),
|
|
("tabBlanket Order", "customer"),
|
|
("tabDunning", "customer"),
|
|
("tabInstallation Note", "customer"),
|
|
("tabDelivery Stop", "customer"),
|
|
("tabPricing Rule", "customer"),
|
|
("tabTax Rule", "customer"),
|
|
("tabCall Log", "customer"),
|
|
("tabProcess Statement Of Accounts Customer", "customer"),
|
|
]
|
|
|
|
# Also update customer_name references in child tables where parent=customer name
|
|
CUST_PARENT_REFS = [
|
|
("tabHas Role", "parent"),
|
|
("tabDynamic Link", "link_name"),
|
|
]
|
|
|
|
all_cust_refs = CUST_REFS + CUST_PARENT_REFS
|
|
|
|
batch_rename("tabCustomer", cust_rename, all_cust_refs, "Customer")
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 2: RENAME SERVICE LOCATIONS
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 2: RENAME SERVICE LOCATIONS")
|
|
print("="*60)
|
|
|
|
locations = frappe.db.sql("""
|
|
SELECT name, address_line, city, legacy_delivery_id
|
|
FROM "tabService Location"
|
|
WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id > 0
|
|
ORDER BY legacy_delivery_id
|
|
""", as_dict=True)
|
|
|
|
loc_rename = {}
|
|
seen_loc = set()
|
|
for loc in locations:
|
|
addr = (loc["address_line"] or "").strip()
|
|
city = (loc["city"] or "").strip()
|
|
|
|
if addr and city:
|
|
# Clean up address for use as document name
|
|
new_name = "{}, {}".format(addr, city)
|
|
# Frappe name max is 140 chars, keep it reasonable
|
|
if len(new_name) > 120:
|
|
new_name = new_name[:120]
|
|
# Remove chars that cause issues in URLs
|
|
new_name = new_name.replace("/", "-").replace("\\", "-")
|
|
elif addr:
|
|
new_name = addr[:120]
|
|
else:
|
|
new_name = "LOC-{}".format(loc["legacy_delivery_id"])
|
|
|
|
# Handle duplicates (same address, different delivery)
|
|
if new_name in seen_loc:
|
|
new_name = "{} [{}]".format(new_name, loc["legacy_delivery_id"])
|
|
seen_loc.add(new_name)
|
|
|
|
if new_name != loc["name"]:
|
|
loc_rename[loc["name"]] = new_name
|
|
|
|
print("Locations to rename: {} (of {})".format(len(loc_rename), len(locations)))
|
|
|
|
LOC_REFS = [
|
|
("tabService Subscription", "service_location"),
|
|
("tabService Equipment", "service_location"),
|
|
("tabDispatch Job", "service_location"),
|
|
("tabIssue", "service_location"),
|
|
]
|
|
|
|
batch_rename("tabService Location", loc_rename, LOC_REFS, "Service Location")
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 3: RENAME SERVICE EQUIPMENT
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 3: RENAME SERVICE EQUIPMENT")
|
|
print("="*60)
|
|
|
|
equipment = frappe.db.sql("""
|
|
SELECT name, legacy_device_id FROM "tabService Equipment"
|
|
WHERE legacy_device_id IS NOT NULL AND legacy_device_id > 0
|
|
ORDER BY legacy_device_id
|
|
""", as_dict=True)
|
|
|
|
eq_rename = {}
|
|
seen_eq = set()
|
|
for eq in equipment:
|
|
new_name = "EQ-{}".format(eq["legacy_device_id"])
|
|
if new_name == eq["name"]:
|
|
continue
|
|
if new_name in seen_eq:
|
|
new_name = "EQ-{}-b".format(eq["legacy_device_id"])
|
|
seen_eq.add(new_name)
|
|
eq_rename[eq["name"]] = new_name
|
|
|
|
print("Equipment to rename: {} (of {})".format(len(eq_rename), len(equipment)))
|
|
|
|
EQ_REFS = [
|
|
("tabService Subscription", "device"),
|
|
]
|
|
|
|
batch_rename("tabService Equipment", eq_rename, EQ_REFS, "Service Equipment")
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 4: VERIFY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 4: VERIFY")
|
|
print("="*60)
|
|
|
|
# Sample customers
|
|
print("\nSample Customers:")
|
|
sample_c = frappe.db.sql("""
|
|
SELECT name, customer_name FROM "tabCustomer"
|
|
WHERE disabled = 0 ORDER BY name LIMIT 10
|
|
""", as_dict=True)
|
|
for c in sample_c:
|
|
print(" {} → {}".format(c["name"], c["customer_name"]))
|
|
|
|
# Sample locations
|
|
print("\nSample Service Locations:")
|
|
sample_l = frappe.db.sql("""
|
|
SELECT name, customer, city FROM "tabService Location"
|
|
WHERE status = 'Active' ORDER BY name LIMIT 10
|
|
""", as_dict=True)
|
|
for l in sample_l:
|
|
print(" {} → customer={}".format(l["name"], l["customer"]))
|
|
|
|
# Sample equipment
|
|
print("\nSample Service Equipment:")
|
|
sample_e = frappe.db.sql("""
|
|
SELECT name, equipment_type, serial_number, customer FROM "tabService Equipment"
|
|
ORDER BY name LIMIT 10
|
|
""", as_dict=True)
|
|
for e in sample_e:
|
|
print(" {} → {} sn={} customer={}".format(
|
|
e["name"], e["equipment_type"], e["serial_number"], e["customer"]))
|
|
|
|
# Check for orphaned references
|
|
print("\nOrphan check:")
|
|
orphan_sub = frappe.db.sql("""
|
|
SELECT COUNT(*) FROM "tabService Subscription" ss
|
|
WHERE ss.customer IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM "tabCustomer" c WHERE c.name = ss.customer)
|
|
""")[0][0]
|
|
print(" Subscriptions with invalid customer ref: {}".format(orphan_sub))
|
|
|
|
orphan_eq = frappe.db.sql("""
|
|
SELECT COUNT(*) FROM "tabService Equipment" eq
|
|
WHERE eq.service_location IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM "tabService Location" sl WHERE sl.name = eq.service_location)
|
|
""")[0][0]
|
|
print(" Equipment with invalid location ref: {}".format(orphan_eq))
|
|
|
|
frappe.clear_cache()
|
|
elapsed = time.time() - T_TOTAL
|
|
print("\n" + "="*60)
|
|
print("DONE in {:.1f}s — cache cleared".format(elapsed))
|
|
print("="*60)
|