- Add PostgreSQL performance indexes migration script (1000x faster queries) Sales Invoice: 1,248ms → 28ms, Payment Entry: 443ms → 31ms Indexes on customer/party columns for all major tables - Disable 3CX poller (PBX_ENABLED flag, using Twilio instead) - Add TelephonyPage: full CRUD UI for Routr/Fonoster resources (trunks, agents, credentials, numbers, domains, peers) - Add PhoneModal + usePhone composable (Twilio WebRTC softphone) - Lazy-load invoices/payments (initial 5, expand on demand) - Parallelize all API calls in ClientDetailPage (no waterfall) - Add targo-hub service (SSE relay, SMS, voice, telephony API) - Customer portal: invoice detail, ticket detail, messages pages - Remove dead Ollama nginx upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
425 lines
17 KiB
Python
425 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Clean reimport of native Subscriptions from Service Subscription (source of truth).
|
|
|
|
Fixes the migration issue where Phase 3/4 created hex customer IDs (CUST-04ea550dcf)
|
|
instead of mapping to the correct sequential IDs (CUST-988).
|
|
|
|
Steps:
|
|
1. Purge all native Subscriptions + Plan Details
|
|
2. Recreate Subscriptions from Service Subscription data (correct customer mapping)
|
|
3. Delete orphaned hex customers (no remaining FK references)
|
|
4. Verify totals match
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/reimport_subscriptions.py [--dry-run]
|
|
|
|
Log: stdout (run with tee)
|
|
"""
|
|
import sys
|
|
import os
|
|
import uuid
|
|
import time
|
|
import html
|
|
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
|
|
if DRY_RUN:
|
|
print("*** DRY RUN — no changes will be written ***")
|
|
|
|
ADMIN = "Administrator"
|
|
COMPANY = "TARGO"
|
|
TAX_TEMPLATE = "QC TPS 5% + TVQ 9.975% - T"
|
|
|
|
|
|
def uid(prefix=""):
|
|
return prefix + uuid.uuid4().hex[:10]
|
|
|
|
|
|
def now():
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 1: PURGE native Subscriptions
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 1: PURGE EXISTING SUBSCRIPTIONS")
|
|
print("=" * 70)
|
|
|
|
sub_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription"')[0][0]
|
|
spd_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan Detail"')[0][0]
|
|
sp_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan"')[0][0]
|
|
|
|
print(f" Current counts: {sub_count} Subscriptions, {spd_count} Plan Details, {sp_count} Plans")
|
|
|
|
if not DRY_RUN:
|
|
frappe.db.sql('DELETE FROM "tabSubscription Plan Detail"')
|
|
frappe.db.sql('DELETE FROM "tabSubscription"')
|
|
frappe.db.sql('DELETE FROM "tabSubscription Plan"')
|
|
frappe.db.commit()
|
|
print(" Purged all Subscriptions, Plan Details, and Plans.")
|
|
else:
|
|
print(" [DRY RUN] Would purge all Subscriptions, Plan Details, and Plans.")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 2: LOAD Service Subscriptions (source of truth)
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 2: LOAD SERVICE SUBSCRIPTIONS")
|
|
print("=" * 70)
|
|
|
|
ss_rows = frappe.db.sql("""
|
|
SELECT name, customer, service_location, plan_name, monthly_price,
|
|
status, service_category, billing_cycle, start_date, end_date,
|
|
speed_down, speed_up, cancellation_date, cancellation_reason,
|
|
legacy_service_id, product_sku, radius_user, radius_password, notes
|
|
FROM "tabService Subscription"
|
|
ORDER BY customer, service_location, monthly_price DESC
|
|
""", as_dict=True)
|
|
|
|
total = len(ss_rows)
|
|
active = sum(1 for r in ss_rows if r["status"] == "Actif")
|
|
customers = len(set(r["customer"] for r in ss_rows))
|
|
print(f" Loaded {total} Service Subscriptions ({active} active) for {customers} customers")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 3: BUILD Subscription Plans from unique SKUs
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 3: CREATE SUBSCRIPTION PLANS")
|
|
print("=" * 70)
|
|
t0 = time.time()
|
|
|
|
# Collect unique SKUs with their typical price and category
|
|
sku_info = {}
|
|
for r in ss_rows:
|
|
sku = r.get("product_sku") or ""
|
|
if not sku:
|
|
continue
|
|
if sku not in sku_info:
|
|
sku_info[sku] = {
|
|
"price": abs(float(r["monthly_price"] or 0)),
|
|
"category": r.get("service_category") or "",
|
|
"billing_cycle": r.get("billing_cycle") or "Mensuel",
|
|
"plan_name": r.get("plan_name") or sku,
|
|
}
|
|
|
|
# Also add a fallback plan for records without SKU
|
|
plan_map = {} # plan_name → plan_id
|
|
plans_created = 0
|
|
|
|
if not DRY_RUN:
|
|
ts = now()
|
|
for sku, info in sku_info.items():
|
|
plan_name = f"PLAN-{sku}"
|
|
plan_id = uid("SP-")
|
|
billing_interval = "Year" if info["billing_cycle"] == "Annuel" else "Month"
|
|
|
|
try:
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabSubscription Plan" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
plan_name, item, currency, price_determination, cost,
|
|
billing_interval, billing_interval_count
|
|
) VALUES (%s, %s, %s, %s, %s, 0, 0,
|
|
%s, %s, 'CAD', 'Fixed Rate', %s, %s, 1)
|
|
""", (plan_id, ts, ts, ADMIN, ADMIN, plan_name, sku, info["price"], billing_interval))
|
|
plan_map[plan_name] = plan_id
|
|
plans_created += 1
|
|
except Exception as e:
|
|
frappe.db.rollback()
|
|
print(f" ERR plan {sku}: {str(e)[:80]}")
|
|
|
|
frappe.db.commit()
|
|
|
|
print(f" Created {plans_created} Subscription Plans [{time.time()-t0:.1f}s]")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 4: CREATE Subscriptions from Service Subscriptions
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 4: CREATE SUBSCRIPTIONS")
|
|
print("=" * 70)
|
|
t0 = time.time()
|
|
|
|
# Verify all customers exist
|
|
all_customers = set(r["customer"] for r in ss_rows)
|
|
existing_customers = set(
|
|
r[0] for r in frappe.db.sql('SELECT name FROM "tabCustomer"')
|
|
)
|
|
missing_customers = all_customers - existing_customers
|
|
if missing_customers:
|
|
print(f" WARNING: {len(missing_customers)} customers referenced but don't exist!")
|
|
for c in list(missing_customers)[:10]:
|
|
print(f" {c}")
|
|
|
|
sub_ok = sub_err = 0
|
|
ts = now()
|
|
|
|
for i, ss in enumerate(ss_rows):
|
|
if DRY_RUN:
|
|
sub_ok += 1
|
|
continue
|
|
|
|
cust = ss["customer"]
|
|
if cust in missing_customers:
|
|
sub_err += 1
|
|
continue
|
|
|
|
# Map status
|
|
if ss["status"] == "Actif":
|
|
status = "Active"
|
|
elif ss["status"] == "Annulé":
|
|
status = "Cancelled"
|
|
elif ss["status"] == "Suspendu":
|
|
status = "Past Due Date"
|
|
else:
|
|
status = "Active" # default
|
|
|
|
# Price
|
|
price = float(ss["monthly_price"] or 0)
|
|
|
|
# Start date
|
|
start_date = ss.get("start_date") or "2020-01-01"
|
|
if hasattr(start_date, "strftime"):
|
|
start_date = start_date.strftime("%Y-%m-%d")
|
|
|
|
# End/cancellation date
|
|
end_date = ss.get("end_date")
|
|
if hasattr(end_date, "strftime"):
|
|
end_date = end_date.strftime("%Y-%m-%d")
|
|
cancel_date = ss.get("cancellation_date")
|
|
if hasattr(cancel_date, "strftime"):
|
|
cancel_date = cancel_date.strftime("%Y-%m-%d")
|
|
|
|
# Billing frequency
|
|
billing_freq = "A" if ss.get("billing_cycle") == "Annuel" else "M"
|
|
|
|
# SKU / plan
|
|
sku = ss.get("product_sku") or ""
|
|
plan_name = f"PLAN-{sku}" if sku else ""
|
|
|
|
# Category
|
|
category = ss.get("service_category") or ""
|
|
|
|
# Description: plan_name from SS (unescape HTML entities from legacy data)
|
|
description = html.unescape(ss.get("plan_name") or "")
|
|
|
|
sub_id = uid("SUB-")
|
|
|
|
try:
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabSubscription" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
party_type, party, company, status,
|
|
start_date, generate_invoice_at, days_until_due,
|
|
follow_calendar_months, generate_new_invoices_past_due_date,
|
|
submit_invoice, cancel_at_period_end,
|
|
sales_tax_template,
|
|
additional_discount_amount,
|
|
radius_user, radius_pwd, legacy_service_id,
|
|
service_location,
|
|
actual_price, custom_description, item_code, item_group,
|
|
billing_frequency,
|
|
cancelation_date
|
|
) VALUES (
|
|
%s, %s, %s, %s, %s, 0, 0,
|
|
'Customer', %s, %s, %s,
|
|
%s, 'Beginning of the current subscription period', 30,
|
|
1, 1, 0, 0,
|
|
%s, 0,
|
|
%s, %s, %s,
|
|
%s,
|
|
%s, %s, %s, %s, %s,
|
|
%s
|
|
)
|
|
""", (sub_id, ts, ts, ADMIN, ADMIN,
|
|
cust, COMPANY, status,
|
|
start_date,
|
|
TAX_TEMPLATE,
|
|
ss.get("radius_user") or None,
|
|
ss.get("radius_password") or None,
|
|
ss.get("legacy_service_id"),
|
|
ss.get("service_location") or None,
|
|
price, description, sku, category, billing_freq,
|
|
cancel_date))
|
|
|
|
# Create plan detail child record
|
|
if plan_name and plan_name in plan_map:
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabSubscription Plan Detail" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
plan, qty,
|
|
parent, parentfield, parenttype
|
|
) VALUES (%s, %s, %s, %s, %s, 0, 1,
|
|
%s, 1,
|
|
%s, 'plans', 'Subscription')
|
|
""", (uid("SPD-"), ts, ts, ADMIN, ADMIN, plan_name, sub_id))
|
|
|
|
sub_ok += 1
|
|
|
|
if sub_ok % 2000 == 0:
|
|
frappe.db.commit()
|
|
print(f" [{sub_ok}/{total}] created... [{time.time()-t0:.0f}s]")
|
|
|
|
except Exception as e:
|
|
sub_err += 1
|
|
frappe.db.rollback()
|
|
if sub_err <= 20:
|
|
print(f" ERR {cust}/{ss['name']}: {str(e)[:100]}")
|
|
|
|
if not DRY_RUN:
|
|
frappe.db.commit()
|
|
|
|
print(f" Created {sub_ok} Subscriptions, {sub_err} errors [{time.time()-t0:.1f}s]")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 5: CLEANUP — Delete orphaned hex customers
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 5: CLEANUP ORPHANED HEX CUSTOMERS")
|
|
print("=" * 70)
|
|
t0 = time.time()
|
|
|
|
# Find hex-pattern customers (non-numeric suffix after CUST-)
|
|
hex_customers = frappe.db.sql("""
|
|
SELECT name, customer_name, legacy_account_id
|
|
FROM "tabCustomer"
|
|
WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$'
|
|
""", as_dict=True)
|
|
|
|
print(f" Found {len(hex_customers)} hex-pattern customers")
|
|
|
|
if hex_customers:
|
|
# Check which hex customers still have FK references using subqueries (avoid ANY() limits)
|
|
hex_subquery = """(SELECT name FROM "tabCustomer" WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$')"""
|
|
|
|
fk_tables = [
|
|
("tabSales Invoice", "customer"),
|
|
("tabPayment Entry", "party"),
|
|
("tabIssue", "customer"),
|
|
("tabService Location", "customer"),
|
|
("tabService Subscription", "customer"),
|
|
("tabSubscription", "party"),
|
|
("tabService Equipment", "customer"),
|
|
]
|
|
|
|
referenced = set()
|
|
for table, col in fk_tables:
|
|
try:
|
|
refs = frappe.db.sql(
|
|
f'SELECT DISTINCT "{col}" FROM "{table}" WHERE "{col}" IN {hex_subquery}'
|
|
)
|
|
for r in refs:
|
|
referenced.add(r[0])
|
|
print(f" {table:30s} {col:15s} → {len(refs)} hex refs")
|
|
except Exception as e:
|
|
frappe.db.rollback()
|
|
print(f" ERR {table}: {str(e)[:60]}")
|
|
|
|
orphaned = [c for c in hex_customers if c["name"] not in referenced]
|
|
still_used = [c for c in hex_customers if c["name"] in referenced]
|
|
|
|
print(f" Still referenced (keep): {len(still_used)}")
|
|
print(f" Orphaned (can delete): {len(orphaned)}")
|
|
|
|
if orphaned and not DRY_RUN:
|
|
# Delete orphaned hex customers using subquery (avoid ANY() limits)
|
|
# First, create a temp table of referenced hex customers
|
|
frappe.db.sql("""
|
|
DELETE FROM "tabDynamic Link"
|
|
WHERE link_doctype = 'Customer'
|
|
AND link_name IN (SELECT name FROM "tabCustomer"
|
|
WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$'
|
|
AND name NOT IN (SELECT DISTINCT party FROM "tabSubscription" WHERE party IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabSales Invoice" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT party FROM "tabPayment Entry" WHERE party IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabIssue" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Subscription" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Equipment" WHERE customer IS NOT NULL)
|
|
)
|
|
""")
|
|
frappe.db.commit()
|
|
|
|
deleted = frappe.db.sql("""
|
|
DELETE FROM "tabCustomer"
|
|
WHERE name ~ '^CUST-' AND name !~ '^CUST-[0-9]+$'
|
|
AND name NOT IN (SELECT DISTINCT party FROM "tabSubscription" WHERE party IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabSales Invoice" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT party FROM "tabPayment Entry" WHERE party IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabIssue" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Subscription" WHERE customer IS NOT NULL)
|
|
AND name NOT IN (SELECT DISTINCT customer FROM "tabService Equipment" WHERE customer IS NOT NULL)
|
|
""")
|
|
frappe.db.commit()
|
|
print(f" Deleted orphaned hex customers")
|
|
elif orphaned:
|
|
print(f" [DRY RUN] Would delete {len(orphaned)} orphaned hex customers")
|
|
else:
|
|
print(" No hex-pattern customers found.")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# STEP 6: VERIFY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("STEP 6: VERIFICATION")
|
|
print("=" * 70)
|
|
|
|
new_sub_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription"')[0][0]
|
|
new_spd_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan Detail"')[0][0]
|
|
new_sp_count = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription Plan"')[0][0]
|
|
cust_count = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer"')[0][0]
|
|
|
|
print(f" Subscriptions: {new_sub_count}")
|
|
print(f" Plan Details: {new_spd_count}")
|
|
print(f" Plans: {new_sp_count}")
|
|
print(f" Customers: {cust_count}")
|
|
|
|
# Check for customer ID consistency: all subscriptions should point to sequential customers
|
|
hex_refs = frappe.db.sql("""
|
|
SELECT COUNT(*) FROM "tabSubscription"
|
|
WHERE party ~ '^CUST-' AND party !~ '^CUST-[0-9]+$'
|
|
""")[0][0]
|
|
print(f" Subscriptions pointing to hex customers: {hex_refs} (should be 0)")
|
|
|
|
# Spot-check: LPB4 customer (legacy_account_id = 4)
|
|
lpb4 = frappe.db.sql("""
|
|
SELECT name FROM "tabCustomer" WHERE legacy_account_id = 4
|
|
""", as_dict=True)
|
|
if lpb4:
|
|
cust_name = lpb4[0]["name"]
|
|
lpb4_subs = frappe.db.sql("""
|
|
SELECT actual_price, item_code, custom_description, status, billing_frequency
|
|
FROM "tabSubscription"
|
|
WHERE party = %s
|
|
ORDER BY actual_price DESC
|
|
""", (cust_name,), as_dict=True)
|
|
total_price = sum(float(s["actual_price"] or 0) for s in lpb4_subs if s["status"] == "Active")
|
|
print(f"\n Spot-check: {cust_name} (LPB4)")
|
|
for s in lpb4_subs:
|
|
print(f" {s['item_code'] or '':15s} {float(s['actual_price'] or 0):>8.2f} {s['billing_frequency']} {s['status']} {s['custom_description'] or ''}")
|
|
print(f" {'TOTAL':15s} {total_price:>8.2f}$/mo")
|
|
|
|
# Summary
|
|
print("\n" + "=" * 70)
|
|
print("COMPLETE")
|
|
print("=" * 70)
|
|
print(f" Next: bench --site erp.gigafibre.ca clear-cache")
|
|
print()
|