gigafibre-fsm/scripts/migration/reimport_subscriptions.py
louispaulb 4693bcf60c feat: telephony UI, performance indexes, Twilio softphone, lazy-load invoices
- 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>
2026-04-02 13:59:59 -04:00

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