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>
163 lines
6.4 KiB
Python
163 lines
6.4 KiB
Python
"""
|
|
Add churn/cancel reason tracking fields to Customer and Issue doctypes.
|
|
Enables AI-powered retention intelligence and win-back campaigns.
|
|
|
|
Run: docker cp add_churn_fields.py erpnext-backend-1:/tmp/
|
|
docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute "exec(open('/tmp/add_churn_fields.py').read())"
|
|
"""
|
|
import frappe
|
|
|
|
def add_custom_fields(doctype, fields):
|
|
count = 0
|
|
for f in fields:
|
|
fieldname = f["fieldname"]
|
|
exists = frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": fieldname})
|
|
if exists:
|
|
print(f" EXISTS: {doctype}.{fieldname}")
|
|
continue
|
|
doc = frappe.get_doc({"doctype": "Custom Field", "dt": doctype, **f})
|
|
doc.insert(ignore_permissions=True)
|
|
count += 1
|
|
print(f" ADDED: {doctype}.{fieldname}")
|
|
return count
|
|
|
|
# ── Customer: Churn/Retention fields ─────────────────────────────────────────
|
|
|
|
customer_fields = [
|
|
# Section break for churn tracking
|
|
{"fieldname": "churn_section", "fieldtype": "Section Break",
|
|
"label": "Rétention / Désabonnement", "insert_after": "notes_internal",
|
|
"collapsible": 1},
|
|
|
|
# Cancel status
|
|
{"fieldname": "churn_status", "fieldtype": "Select",
|
|
"label": "Statut rétention",
|
|
"options": "\nActif\nÀ risque\nDésabonné\nRécupéré",
|
|
"insert_after": "churn_section"},
|
|
|
|
{"fieldname": "churn_cb1", "fieldtype": "Column Break",
|
|
"insert_after": "churn_status"},
|
|
|
|
# Cancel date
|
|
{"fieldname": "cancel_date", "fieldtype": "Date",
|
|
"label": "Date désabonnement",
|
|
"insert_after": "churn_cb1"},
|
|
|
|
# Cancel reason
|
|
{"fieldname": "cancel_reason", "fieldtype": "Select",
|
|
"label": "Raison désabonnement",
|
|
"options": "\nCompétiteur - Promotion\nCompétiteur - Prix\nCompétiteur - Service\nPrix trop élevé\nQualité WiFi\nPannes fréquentes\nService client\nDéménagement hors zone\nDéménagement dans zone\nDécès\nAutre",
|
|
"insert_after": "cancel_date"},
|
|
|
|
{"fieldname": "churn_cb2", "fieldtype": "Column Break",
|
|
"insert_after": "cancel_reason"},
|
|
|
|
# Competitor info
|
|
{"fieldname": "cancel_competitor", "fieldtype": "Data",
|
|
"label": "Compétiteur",
|
|
"description": "Bell, Vidéotron, Fizz, etc.",
|
|
"insert_after": "churn_cb2",
|
|
"depends_on": "eval:['Compétiteur - Promotion','Compétiteur - Prix','Compétiteur - Service'].includes(doc.cancel_reason)"},
|
|
|
|
{"fieldname": "cancel_competitor_offer", "fieldtype": "Small Text",
|
|
"label": "Offre compétiteur",
|
|
"description": "Ex: 6 mois gratuit, 49.99$/mois fibre 1Gbps",
|
|
"insert_after": "cancel_competitor",
|
|
"depends_on": "eval:['Compétiteur - Promotion','Compétiteur - Prix','Compétiteur - Service'].includes(doc.cancel_reason)"},
|
|
|
|
# Notes
|
|
{"fieldname": "cancel_notes", "fieldtype": "Small Text",
|
|
"label": "Notes rétention",
|
|
"description": "Contexte: ce que le client a dit, offre proposée, etc.",
|
|
"insert_after": "cancel_competitor_offer"},
|
|
|
|
# Win-back tracking
|
|
{"fieldname": "winback_section", "fieldtype": "Section Break",
|
|
"label": "Récupération", "insert_after": "cancel_notes",
|
|
"collapsible": 1,
|
|
"depends_on": "eval:doc.churn_status=='Désabonné'"},
|
|
|
|
{"fieldname": "winback_attempts", "fieldtype": "Int",
|
|
"label": "Tentatives récupération", "default": "0",
|
|
"insert_after": "winback_section"},
|
|
|
|
{"fieldname": "winback_last_date", "fieldtype": "Date",
|
|
"label": "Dernière tentative",
|
|
"insert_after": "winback_attempts"},
|
|
|
|
{"fieldname": "winback_cb", "fieldtype": "Column Break",
|
|
"insert_after": "winback_last_date"},
|
|
|
|
{"fieldname": "winback_date", "fieldtype": "Date",
|
|
"label": "Date récupération",
|
|
"insert_after": "winback_cb",
|
|
"depends_on": "eval:doc.churn_status=='Récupéré'"},
|
|
|
|
{"fieldname": "winback_offer", "fieldtype": "Small Text",
|
|
"label": "Offre accordée",
|
|
"description": "Offre qui a convaincu le client de revenir",
|
|
"insert_after": "winback_date",
|
|
"depends_on": "eval:doc.churn_status=='Récupéré'"},
|
|
|
|
# Risk score (AI-populated)
|
|
{"fieldname": "churn_risk_score", "fieldtype": "Int",
|
|
"label": "Score risque désabonnement",
|
|
"description": "0-100, calculé par l'IA basé sur le comportement",
|
|
"insert_after": "winback_offer", "read_only": 1},
|
|
]
|
|
|
|
# ── Issue: Enhanced categorization ───────────────────────────────────────────
|
|
|
|
issue_fields = [
|
|
# Customer link (if not already present)
|
|
{"fieldname": "customer", "fieldtype": "Link",
|
|
"label": "Client", "options": "Customer",
|
|
"insert_after": "naming_series"},
|
|
|
|
# Outage tracking
|
|
{"fieldname": "outage_section", "fieldtype": "Section Break",
|
|
"label": "Panne / Outage", "insert_after": "resolution_details",
|
|
"collapsible": 1},
|
|
|
|
{"fieldname": "outage_type", "fieldtype": "Select",
|
|
"label": "Type de panne",
|
|
"options": "\nPanne isolée\nPanne secteur\nCoupure fibre\nPanne OLT\nPanne backbone\nPanne courant",
|
|
"insert_after": "outage_section"},
|
|
|
|
{"fieldname": "outage_cb", "fieldtype": "Column Break",
|
|
"insert_after": "outage_type"},
|
|
|
|
{"fieldname": "affected_count", "fieldtype": "Int",
|
|
"label": "Clients affectés",
|
|
"insert_after": "outage_cb"},
|
|
|
|
{"fieldname": "olt_name", "fieldtype": "Data",
|
|
"label": "OLT",
|
|
"insert_after": "affected_count"},
|
|
|
|
{"fieldname": "olt_port", "fieldtype": "Data",
|
|
"label": "Port OLT",
|
|
"insert_after": "olt_name"},
|
|
|
|
# AI diagnosis
|
|
{"fieldname": "ai_diagnosis", "fieldtype": "Small Text",
|
|
"label": "Diagnostic IA",
|
|
"insert_after": "olt_port", "read_only": 1},
|
|
|
|
# Cancel reason (for support tickets about cancellation)
|
|
{"fieldname": "cancel_intent", "fieldtype": "Check",
|
|
"label": "Intention de désabonnement",
|
|
"insert_after": "ai_diagnosis"},
|
|
]
|
|
|
|
# ── Apply ────────────────────────────────────────────────────────────────────
|
|
|
|
print("Adding churn/retention fields to Customer...")
|
|
c1 = add_custom_fields("Customer", customer_fields)
|
|
|
|
print("\nAdding outage/categorization fields to Issue...")
|
|
c2 = add_custom_fields("Issue", issue_fields)
|
|
|
|
frappe.db.commit()
|
|
print(f"\nDone: {c1} Customer fields + {c2} Issue fields added")
|