gigafibre-fsm/scripts/migration/import_services_and_enrich_customers.py
louispaulb 101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- 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>
2026-03-31 07:34:41 -04:00

453 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 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="10.100.80.100",
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 = 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()