Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
454 lines
17 KiB
Python
454 lines
17 KiB
Python
"""
|
|
Import active services as Service Subscriptions and enrich Customer records
|
|
with full account details (phone, email, stripe, PPA, notes).
|
|
|
|
Also adds custom fields to Service Subscription for RADIUS/legacy data.
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_services_and_enrich_customers.py
|
|
"""
|
|
import html
|
|
import frappe
|
|
import pymysql
|
|
import os
|
|
import time
|
|
import hashlib
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
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()
|
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
|
|
conn = pymysql.connect(
|
|
host="legacy-db",
|
|
user="facturation",
|
|
password="*******",
|
|
database="gestionclient",
|
|
cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 1: Add custom fields to Service Subscription
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 1: ADD CUSTOM FIELDS")
|
|
print("="*60)
|
|
|
|
custom_fields = [
|
|
("legacy_service_id", "Legacy Service ID", "Int", 30),
|
|
("radius_user", "RADIUS User", "Data", 31),
|
|
("radius_password", "RADIUS Password", "Data", 32),
|
|
("product_sku", "Product SKU", "Data", 33),
|
|
("device", "Device", "Link", 34), # options = Service Equipment
|
|
]
|
|
|
|
for fname, label, ftype, idx in custom_fields:
|
|
exists = frappe.db.sql("""
|
|
SELECT name FROM "tabDocField"
|
|
WHERE parent = 'Service Subscription' AND fieldname = %s
|
|
""", (fname,))
|
|
if not exists:
|
|
opts = "Service Equipment" if fname == "device" else None
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabDocField" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
parent, parentfield, parenttype,
|
|
fieldname, label, fieldtype, options, reqd, read_only, hidden
|
|
) VALUES (
|
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, %(idx)s,
|
|
'Service Subscription', 'fields', 'DocType',
|
|
%(fname)s, %(label)s, %(ftype)s, %(opts)s, 0, 0, 0
|
|
)
|
|
""", {
|
|
"name": "ss-{}-{}".format(fname, int(time.time())),
|
|
"now": now_str, "idx": idx,
|
|
"fname": fname, "label": label, "ftype": ftype, "opts": opts,
|
|
})
|
|
# Add column to table
|
|
col_type = "bigint" if ftype == "Int" else "varchar(140)"
|
|
try:
|
|
frappe.db.sql('ALTER TABLE "tabService Subscription" ADD COLUMN {} {}'.format(fname, col_type))
|
|
except Exception as e:
|
|
if "already exists" not in str(e).lower():
|
|
raise
|
|
frappe.db.commit()
|
|
print(" Added field: {}".format(fname))
|
|
else:
|
|
print(" Field exists: {}".format(fname))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 2: Build lookup maps
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 2: BUILD LOOKUP MAPS")
|
|
print("="*60)
|
|
|
|
# delivery_id → Service Location name
|
|
loc_map = {}
|
|
rows = frappe.db.sql("""
|
|
SELECT name, legacy_delivery_id FROM "tabService Location"
|
|
WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id > 0
|
|
""", as_dict=True)
|
|
for r in rows:
|
|
loc_map[r["legacy_delivery_id"]] = r["name"]
|
|
print("Location map: {} entries".format(len(loc_map)))
|
|
|
|
# account_id → Customer name
|
|
cust_map = {}
|
|
rows = frappe.db.sql("""
|
|
SELECT name, legacy_account_id FROM "tabCustomer"
|
|
WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0
|
|
""", as_dict=True)
|
|
for r in rows:
|
|
cust_map[r["legacy_account_id"]] = r["name"]
|
|
print("Customer map: {} entries".format(len(cust_map)))
|
|
|
|
# device_id → Service Equipment name
|
|
dev_map = {}
|
|
rows = frappe.db.sql("""
|
|
SELECT name, legacy_device_id FROM "tabService Equipment"
|
|
WHERE legacy_device_id IS NOT NULL AND legacy_device_id > 0
|
|
""", as_dict=True)
|
|
for r in rows:
|
|
dev_map[r["legacy_device_id"]] = r["name"]
|
|
print("Device map: {} entries".format(len(dev_map)))
|
|
|
|
# delivery_id → account_id
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT id, account_id FROM delivery")
|
|
del_acct = {}
|
|
for r in cur.fetchall():
|
|
del_acct[r["id"]] = r["account_id"]
|
|
print("Delivery→account map: {} entries".format(len(del_acct)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 3: Import services as Service Subscriptions
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 3: IMPORT SERVICE SUBSCRIPTIONS")
|
|
print("="*60)
|
|
|
|
# Clear existing
|
|
existing_subs = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription"')[0][0]
|
|
if existing_subs > 0:
|
|
frappe.db.sql('DELETE FROM "tabService Subscription"')
|
|
frappe.db.commit()
|
|
print("Deleted {} existing subscriptions".format(existing_subs))
|
|
|
|
# Product category → service_category mapping
|
|
# Legacy: Mensualités fibre, Installation fibre, Mensualités sans fil, Téléphonie,
|
|
# Mensualités télévision, Installation télé, Adresse IP Fixe, Hébergement, etc.
|
|
PROD_CAT_MAP = {
|
|
4: "Internet", # Mensualités sans fil
|
|
32: "Internet", # Mensualités fibre
|
|
8: "Internet", # Installation et équipement internet sans fil
|
|
26: "Internet", # Installation et équipement fibre
|
|
29: "Internet", # Equipement internet fibre
|
|
7: "Internet", # Equipement internet sans fil
|
|
23: "Internet", # Internet camping
|
|
17: "Internet", # Adresse IP Fixe
|
|
16: "Internet", # Téléchargement supplémentaire
|
|
21: "Internet", # Location point à point
|
|
33: "IPTV", # Mensualités télévision
|
|
34: "IPTV", # Installation et équipement télé
|
|
9: "VoIP", # Téléphonie
|
|
15: "Hébergement", # Hébergement
|
|
11: "Hébergement", # Nom de domaine
|
|
30: "Hébergement", # Location espace cloud
|
|
10: "Autre", # Site internet
|
|
13: "Autre", # Location d'espace
|
|
}
|
|
|
|
# payment_recurrence → billing_cycle
|
|
RECUR_MAP = {
|
|
1: "Mensuel",
|
|
2: "Mensuel",
|
|
3: "Trimestriel",
|
|
4: "Annuel",
|
|
}
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT s.id, s.delivery_id, s.device_id, s.product_id, s.status, s.comment,
|
|
s.payment_recurrence, s.hijack, s.hijack_price,
|
|
s.hijack_download_speed, s.hijack_upload_speed,
|
|
s.date_orig, s.date_suspended, s.date_next_invoice, s.date_end_contract,
|
|
s.forfait_internet, s.radius_user, s.radius_pwd,
|
|
p.sku, p.price, p.download_speed, p.upload_speed, p.category as prod_cat
|
|
FROM service s
|
|
LEFT JOIN product p ON s.product_id = p.id
|
|
WHERE s.status = 1
|
|
ORDER BY s.id
|
|
""")
|
|
services = cur.fetchall()
|
|
|
|
# Also get product names
|
|
cur.execute("""
|
|
SELECT pt.product_id, pt.name as prod_name
|
|
FROM product_translate pt
|
|
WHERE pt.language_id = 'francais'
|
|
""")
|
|
prod_names = {}
|
|
for r in cur.fetchall():
|
|
prod_names[r["product_id"]] = r["prod_name"]
|
|
|
|
print("Active services to import: {}".format(len(services)))
|
|
|
|
inserted = 0
|
|
no_location = 0
|
|
|
|
for svc in services:
|
|
# Resolve customer via delivery → account
|
|
customer = None
|
|
service_location = loc_map.get(svc["delivery_id"]) if svc["delivery_id"] else None
|
|
if not service_location:
|
|
no_location += 1
|
|
continue # Skip services without a service location (required field)
|
|
|
|
if svc["delivery_id"] and svc["delivery_id"] in del_acct:
|
|
customer = cust_map.get(del_acct[svc["delivery_id"]])
|
|
if not customer:
|
|
no_location += 1
|
|
continue # Skip services without a customer (required field)
|
|
|
|
# Service category
|
|
prod_cat = svc["prod_cat"] or 0
|
|
service_category = PROD_CAT_MAP.get(prod_cat, "Autre")
|
|
|
|
# Plan name
|
|
plan_name = html.unescape(prod_names.get(svc["product_id"], svc["sku"] or "Unknown"))
|
|
|
|
# Speed (legacy stores in kbps, convert to Mbps)
|
|
speed_down = 0
|
|
speed_up = 0
|
|
if svc["hijack"] and svc["hijack_download_speed"]:
|
|
speed_down = int(svc["hijack_download_speed"]) // 1024
|
|
speed_up = int(svc["hijack_upload_speed"] or 0) // 1024
|
|
elif svc["download_speed"]:
|
|
speed_down = int(svc["download_speed"]) // 1024
|
|
speed_up = int(svc["upload_speed"] or 0) // 1024
|
|
|
|
# Price
|
|
price = float(svc["hijack_price"] or 0) if svc["hijack"] else float(svc["price"] or 0)
|
|
|
|
# Billing cycle
|
|
billing_cycle = RECUR_MAP.get(svc["payment_recurrence"], "Mensuel")
|
|
|
|
# Start date
|
|
start_date = None
|
|
if svc["date_orig"]:
|
|
try:
|
|
start_date = datetime.fromtimestamp(int(svc["date_orig"])).strftime("%Y-%m-%d")
|
|
except (ValueError, OSError):
|
|
pass
|
|
if not start_date:
|
|
start_date = "2020-01-01"
|
|
|
|
# End date (contract)
|
|
end_date = None
|
|
if svc["date_end_contract"]:
|
|
try:
|
|
end_date = datetime.fromtimestamp(int(svc["date_end_contract"])).strftime("%Y-%m-%d")
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
# Device link
|
|
device = dev_map.get(svc["device_id"]) if svc["device_id"] else None
|
|
|
|
# Generate name
|
|
sub_name = "SUB-{}".format(svc["id"])
|
|
|
|
inserted += 1
|
|
frappe.db.sql("""
|
|
INSERT INTO "tabService Subscription" (
|
|
name, creation, modified, modified_by, owner, docstatus, idx,
|
|
customer, service_location, status, service_category,
|
|
plan_name, speed_down, speed_up,
|
|
monthly_price, billing_cycle,
|
|
start_date, end_date, notes,
|
|
legacy_service_id, radius_user, radius_password, product_sku, device
|
|
) VALUES (
|
|
%(name)s, %(now)s, %(now)s, 'Administrator', 'Administrator', 0, 0,
|
|
%(customer)s, %(service_location)s, 'Actif', %(service_category)s,
|
|
%(plan_name)s, %(speed_down)s, %(speed_up)s,
|
|
%(monthly_price)s, %(billing_cycle)s,
|
|
%(start_date)s, %(end_date)s, %(notes)s,
|
|
%(legacy_service_id)s, %(radius_user)s, %(radius_password)s, %(product_sku)s, %(device)s
|
|
)
|
|
""", {
|
|
"name": sub_name,
|
|
"now": now_str,
|
|
"customer": customer,
|
|
"service_location": service_location,
|
|
"service_category": service_category,
|
|
"plan_name": plan_name,
|
|
"speed_down": speed_down,
|
|
"speed_up": speed_up,
|
|
"monthly_price": price,
|
|
"billing_cycle": billing_cycle,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"notes": svc["comment"] if svc["comment"] else None,
|
|
"legacy_service_id": svc["id"],
|
|
"radius_user": svc["radius_user"],
|
|
"radius_password": svc["radius_pwd"],
|
|
"product_sku": svc["sku"],
|
|
"device": device,
|
|
})
|
|
|
|
if inserted % 5000 == 0:
|
|
frappe.db.commit()
|
|
print(" Inserted {}...".format(inserted))
|
|
|
|
frappe.db.commit()
|
|
print("Inserted {} Service Subscriptions ({} skipped - no location/customer)".format(inserted, no_location))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 4: Enrich Customer records with account details
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 4: ENRICH CUSTOMER RECORDS")
|
|
print("="*60)
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT id, customer_id, email, email_autre, tel_home, cell,
|
|
stripe_id, ppa, ppa_name, ppa_code, ppa_branch, ppa_account,
|
|
notes_client, language_id, commercial, vip, mauvais_payeur,
|
|
invoice_delivery, company, contact
|
|
FROM account
|
|
WHERE status = 1
|
|
""")
|
|
accounts = cur.fetchall()
|
|
|
|
print("Active accounts to enrich: {}".format(len(accounts)))
|
|
|
|
updated = 0
|
|
for acct in accounts:
|
|
cust_name = cust_map.get(acct["id"])
|
|
if not cust_name:
|
|
continue
|
|
|
|
# Build update fields
|
|
updates = {}
|
|
sets = []
|
|
|
|
# Email
|
|
if acct["email"] and acct["email"].strip():
|
|
updates["email_id"] = acct["email"].strip()
|
|
sets.append('email_id = %(email_id)s')
|
|
|
|
# Mobile
|
|
cell = (acct["cell"] or "").strip()
|
|
if not cell:
|
|
cell = (acct["tel_home"] or "").strip()
|
|
if cell:
|
|
updates["mobile_no"] = cell
|
|
sets.append('mobile_no = %(mobile_no)s')
|
|
|
|
# Stripe ID
|
|
if acct["stripe_id"] and acct["stripe_id"].strip():
|
|
updates["stripe_id"] = acct["stripe_id"].strip()
|
|
sets.append('stripe_id = %(stripe_id)s')
|
|
|
|
# PPA enabled
|
|
if acct["ppa"]:
|
|
updates["ppa_enabled"] = 1
|
|
sets.append('ppa_enabled = %(ppa_enabled)s')
|
|
|
|
# Language
|
|
lang = "fr" if acct["language_id"] == "francais" else "en"
|
|
updates["language"] = lang
|
|
sets.append('language = %(language)s')
|
|
|
|
# Customer details (notes + contact)
|
|
details_parts = []
|
|
if acct["notes_client"] and acct["notes_client"].strip():
|
|
details_parts.append(acct["notes_client"].strip())
|
|
if acct["contact"] and acct["contact"].strip():
|
|
details_parts.append("Contact: " + acct["contact"].strip())
|
|
if acct["vip"]:
|
|
details_parts.append("[VIP]")
|
|
if acct["mauvais_payeur"]:
|
|
details_parts.append("[MAUVAIS PAYEUR]")
|
|
if acct["commercial"]:
|
|
details_parts.append("[COMMERCIAL]")
|
|
if details_parts:
|
|
updates["customer_details"] = "\n".join(details_parts)
|
|
sets.append('customer_details = %(customer_details)s')
|
|
|
|
if sets:
|
|
updates["cust_name"] = cust_name
|
|
frappe.db.sql(
|
|
'UPDATE "tabCustomer" SET {} WHERE name = %(cust_name)s'.format(", ".join(sets)),
|
|
updates
|
|
)
|
|
updated += 1
|
|
|
|
if updated % 2000 == 0 and updated > 0:
|
|
frappe.db.commit()
|
|
print(" Updated {}...".format(updated))
|
|
|
|
frappe.db.commit()
|
|
print("Updated {} Customer records".format(updated))
|
|
|
|
conn.close()
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# PHASE 5: VERIFY
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "="*60)
|
|
print("PHASE 5: VERIFY")
|
|
print("="*60)
|
|
|
|
total_subs = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription"')[0][0]
|
|
by_cat = frappe.db.sql("""
|
|
SELECT service_category, COUNT(*) as cnt FROM "tabService Subscription"
|
|
GROUP BY service_category ORDER BY cnt DESC
|
|
""", as_dict=True)
|
|
print("Total Service Subscriptions: {}".format(total_subs))
|
|
for c in by_cat:
|
|
print(" {}: {}".format(c["service_category"], c["cnt"]))
|
|
|
|
with_device = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription" WHERE device IS NOT NULL')[0][0]
|
|
with_radius = frappe.db.sql('SELECT COUNT(*) FROM "tabService Subscription" WHERE radius_user IS NOT NULL')[0][0]
|
|
print("\nWith device link: {}".format(with_device))
|
|
print("With RADIUS credentials: {}".format(with_radius))
|
|
|
|
# Customer enrichment
|
|
cust_with_email = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE email_id IS NOT NULL")[0][0]
|
|
cust_with_phone = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE mobile_no IS NOT NULL")[0][0]
|
|
cust_with_stripe = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE stripe_id IS NOT NULL")[0][0]
|
|
cust_with_ppa = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer" WHERE ppa_enabled = 1')[0][0]
|
|
print("\nCustomer enrichment:")
|
|
print(" With email: {}".format(cust_with_email))
|
|
print(" With phone: {}".format(cust_with_phone))
|
|
print(" With Stripe: {}".format(cust_with_stripe))
|
|
print(" With PPA: {}".format(cust_with_ppa))
|
|
|
|
# Sample subscriptions
|
|
samples = frappe.db.sql("""
|
|
SELECT name, customer, service_category, plan_name, speed_down, speed_up,
|
|
monthly_price, radius_user, device, legacy_service_id
|
|
FROM "tabService Subscription" LIMIT 10
|
|
""", as_dict=True)
|
|
print("\nSample subscriptions:")
|
|
for s in samples:
|
|
print(" {} cat={} plan={} {}↓/{}↑ ${} radius={} dev={}".format(
|
|
s["name"], s["service_category"], (s["plan_name"] or "")[:30],
|
|
s["speed_down"], s["speed_up"], s["monthly_price"],
|
|
s["radius_user"] or "-", s["device"] or "-"))
|
|
|
|
elapsed = time.time() - T_TOTAL
|
|
print("\n" + "="*60)
|
|
print("DONE in {:.1f}s".format(elapsed))
|
|
print("="*60)
|
|
|
|
frappe.clear_cache()
|