gigafibre-fsm/erpnext/seed_flow_templates.py
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

460 lines
17 KiB
Python

"""
seed_flow_templates.py — Seed initial Flow Templates (system-owned).
Migrates the 4 hardcoded project templates (fiber_install, phone_service,
move_service, repair_service) from apps/ops/src/config/project-templates.js
into Flow Template docs (is_system=1).
Also seeds:
- residential_onboarding : runs on_contract_signed, ties install + reminders
- quotation_follow_up : runs on_quotation_created, sends reminders
Run (inside backend container):
docker exec -u frappe erpnext-backend-1 bash -c \\
'cd /home/frappe/frappe-bench/sites && \\
/home/frappe/frappe-bench/env/bin/python -c \\
"import frappe; frappe.init(site=\\"erp.gigafibre.ca\\"); frappe.connect(); \\
from seed_flow_templates import seed_all; seed_all()"'
Idempotent: skips templates that already exist by template_name.
"""
import json
import frappe
# Flow definition schema:
# {
# "version": 1,
# "trigger": { "event": "...", "condition": "" },
# "variables": {...}, # flow-level defaults
# "steps": [ <Step>, ... ]
# }
#
# Step schema:
# {
# "id": "step_xxx", # stable ID
# "kind": "dispatch_job"|"issue"|"notify"|"webhook"|"erp_update"|"wait"|"condition"|"subscription_activate",
# "label": "...", # human-readable
# "parent_id": null | "step_yyy",
# "branch": null | "yes" | "no" | custom,
# "depends_on": ["step_xxx"], # array of step IDs; empty = run at flow start
# "trigger": { # when to execute this step
# "type": "on_flow_start" | "on_prev_complete" | "after_delay" | "on_date" | "on_webhook" | "manual",
# "delay_hours": 24, # for after_delay
# "delay_days": 7, # for after_delay
# "at": "2026-05-01T09:00", # for on_date
# },
# "payload": { ... } # kind-specific fields (see below)
# }
#
# Payloads by kind:
# dispatch_job: { subject, job_type, priority, duration_h, assigned_group,
# on_open_webhook, on_close_webhook, merge_key }
# issue: { subject, description, priority, raised_by, issue_type }
# notify: { channel: "sms"|"email", to, template_id, subject, body }
# webhook: { url, method: "POST", headers, body_template }
# erp_update: { doctype, docname_ref, fields: {field: value} }
# wait: {} (uses trigger.delay_hours/delay_days)
# condition: { field, op: "==|!=|<|>|<=|>=|in|not_in", value }
# Children on branch "yes" / "no"
# subscription_activate: { subscription_ref }
# ---------------------------------------------------------------------------
# Helper builders (keep seed JSON readable)
# ---------------------------------------------------------------------------
def _dispatch_step(sid, label, subject, job_type, priority, duration_h,
group, depends_on=None, merge_key=None,
open_wh="", close_wh=""):
return {
"id": sid,
"kind": "dispatch_job",
"label": label,
"parent_id": None,
"branch": None,
"depends_on": depends_on or [],
"trigger": {"type": "on_prev_complete" if depends_on else "on_flow_start"},
"payload": {
"subject": subject,
"job_type": job_type,
"priority": priority,
"duration_h": duration_h,
"assigned_group": group,
"merge_key": merge_key or sid,
"on_open_webhook": open_wh,
"on_close_webhook": close_wh,
},
}
def _notify_step(sid, label, channel, template_id, depends_on=None,
trigger_type="on_prev_complete", delay_hours=None):
trig = {"type": trigger_type}
if delay_hours:
trig["delay_hours"] = delay_hours
return {
"id": sid,
"kind": "notify",
"label": label,
"parent_id": None,
"branch": None,
"depends_on": depends_on or [],
"trigger": trig,
"payload": {
"channel": channel,
"to": "{{customer.primary_phone}}" if channel == "sms" else "{{customer.email_id}}",
"template_id": template_id,
},
}
def _issue_step(sid, label, subject, depends_on=None):
return {
"id": sid,
"kind": "issue",
"label": label,
"parent_id": None,
"branch": None,
"depends_on": depends_on or [],
"trigger": {"type": "on_prev_complete" if depends_on else "on_flow_start"},
"payload": {
"subject": subject,
"priority": "Medium",
"issue_type": "Suivi",
},
}
def _wait_step(sid, label, delay_hours=None, delay_days=None, depends_on=None):
trig = {"type": "after_delay"}
if delay_hours:
trig["delay_hours"] = delay_hours
if delay_days:
trig["delay_days"] = delay_days
return {
"id": sid,
"kind": "wait",
"label": label,
"parent_id": None,
"branch": None,
"depends_on": depends_on or [],
"trigger": trig,
"payload": {},
}
# ---------------------------------------------------------------------------
# Seed definitions
# ---------------------------------------------------------------------------
def _tpl_fiber_install():
return {
"template_name": "Installation fibre résidentielle",
"category": "Internet",
"applies_to": "Service Contract",
"icon": "cable",
"description": "Vérification pré-install, installation, activation, test de débit",
"is_system": 1,
"trigger_event": "manual",
"flow_definition": {
"version": 1,
"trigger": {"event": "manual", "condition": ""},
"variables": {},
"steps": [
_dispatch_step("s1", "Vérification pré-installation",
"Vérification pré-installation (éligibilité & OLT)",
"Autre", "medium", 0.5, "Admin", merge_key="fiber_pre_check"),
_dispatch_step("s2", "Installation fibre",
"Installation fibre chez le client",
"Installation", "high", 3, "Tech Targo",
depends_on=["s1"], merge_key="fiber_install_visit"),
_dispatch_step("s3", "Activation & config ONT",
"Activation du service & configuration ONT",
"Installation", "high", 0.5, "Admin",
depends_on=["s2"], merge_key="fiber_activation"),
_dispatch_step("s4", "Test de débit",
"Test de débit & validation client",
"Dépannage", "medium", 0.5, "Tech Targo",
depends_on=["s3"], merge_key="fiber_speed_test"),
],
},
}
def _tpl_phone_service():
return {
"template_name": "Service téléphonique résidentiel",
"category": "Téléphonie",
"applies_to": "Service Contract",
"icon": "phone_in_talk",
"description": "Importation du numéro, installation fibre, portage du numéro",
"is_system": 1,
"trigger_event": "manual",
"flow_definition": {
"version": 1,
"trigger": {"event": "manual", "condition": ""},
"variables": {},
"steps": [
_dispatch_step("s1", "Importer numéro",
"Importer le numéro de téléphone",
"Autre", "medium", 0.5, "Admin", merge_key="port_phone_request"),
_dispatch_step("s2", "Installation fibre",
"Installation fibre chez le client",
"Installation", "high", 3, "Tech Targo",
depends_on=["s1"], merge_key="fiber_install_visit"),
_dispatch_step("s3", "Portage numéro",
"Portage du numéro vers Gigafibre",
"Autre", "medium", 0.5, "Admin",
depends_on=["s2"], merge_key="port_phone_execute"),
_dispatch_step("s4", "Test téléphonie",
"Validation et test du service téléphonique",
"Dépannage", "medium", 0.5, "Tech Targo",
depends_on=["s3"], merge_key="phone_service_test"),
],
},
}
def _tpl_move_service():
return {
"template_name": "Déménagement de service",
"category": "Déménagement",
"applies_to": "Service Contract",
"icon": "local_shipping",
"description": "Retrait ancien site, installation nouveau site, transfert abonnement",
"is_system": 1,
"trigger_event": "manual",
"flow_definition": {
"version": 1,
"trigger": {"event": "manual", "condition": ""},
"variables": {},
"steps": [
_dispatch_step("s1", "Préparation",
"Préparation déménagement (vérifier éligibilité nouveau site)",
"Autre", "medium", 0.5, "Admin", merge_key="move_prep"),
_dispatch_step("s2", "Retrait ancien site",
"Retrait équipement ancien site",
"Retrait", "medium", 1, "Tech Targo",
depends_on=["s1"], merge_key="move_removal"),
_dispatch_step("s3", "Installation nouveau site",
"Installation au nouveau site",
"Installation", "high", 3, "Tech Targo",
depends_on=["s2"], merge_key="fiber_install_visit"),
_dispatch_step("s4", "Transfert abonnement",
"Transfert abonnement & mise à jour adresse",
"Autre", "medium", 0.5, "Admin",
depends_on=["s3"], merge_key="move_transfer"),
],
},
}
def _tpl_repair_service():
return {
"template_name": "Réparation service client",
"category": "Dépannage",
"applies_to": "Issue",
"icon": "build",
"description": "Diagnostic, intervention terrain, validation",
"is_system": 1,
"trigger_event": "on_issue_opened",
"flow_definition": {
"version": 1,
"trigger": {"event": "on_issue_opened", "condition": ""},
"variables": {},
"steps": [
_dispatch_step("s1", "Diagnostic à distance",
"Diagnostic à distance",
"Dépannage", "high", 0.5, "Admin", merge_key="repair_diag"),
_dispatch_step("s2", "Intervention terrain",
"Intervention terrain",
"Réparation", "high", 2, "Tech Targo",
depends_on=["s1"], merge_key="repair_visit"),
_dispatch_step("s3", "Validation client",
"Validation & suivi client",
"Dépannage", "medium", 0.5, "Admin",
depends_on=["s2"], merge_key="repair_validate"),
],
},
}
def _tpl_residential_onboarding():
"""
Flow complet déclenché à la signature du Service Contract résidentiel.
Ferme la boucle CTR-00004 : installation + rappels + satisfaction + renouvellement.
"""
return {
"template_name": "Onboarding résidentiel (post-signature)",
"category": "Onboarding",
"applies_to": "Service Contract",
"icon": "rocket_launch",
"description": "Flow complet après signature: SMS bienvenue, installation, test, survey, renouvellement",
"is_system": 1,
"trigger_event": "on_contract_signed",
"trigger_condition": "contract.contract_type == 'Résidentiel'",
"flow_definition": {
"version": 1,
"trigger": {
"event": "on_contract_signed",
"condition": "contract.contract_type == 'Résidentiel'",
},
"variables": {},
"steps": [
# Immédiat — accueil
_notify_step("welcome_sms", "SMS de bienvenue",
channel="sms", template_id="welcome_residential",
trigger_type="on_flow_start"),
_issue_step("onboarding_ticket",
"Ticket suivi onboarding",
"Onboarding résidentiel — suivi client"),
# Installation (dépend de rien, démarre immédiatement)
_dispatch_step("install", "Installation fibre",
"Installation fibre chez le client",
"Installation", "high", 3, "Tech Targo",
merge_key="fiber_install_visit",
open_wh="", close_wh=""),
# Activation abonnement après installation complétée
{
"id": "activate_sub",
"kind": "subscription_activate",
"label": "Activer l'abonnement",
"parent_id": None,
"branch": None,
"depends_on": ["install"],
"trigger": {"type": "on_prev_complete"},
"payload": {"subscription_ref": "{{contract.subscription}}"},
},
# SMS confirmation service actif
_notify_step("active_sms", "SMS service actif",
channel="sms", template_id="service_activated",
depends_on=["activate_sub"]),
# Survey satisfaction 24h après install
_wait_step("wait_24h", "Attendre 24h après install",
delay_hours=24, depends_on=["install"]),
_notify_step("survey_sms", "SMS sondage satisfaction",
channel="sms", template_id="satisfaction_survey",
depends_on=["wait_24h"]),
# Rappel renouvellement à 11 mois (anticipation du renouvel à 12 mois)
_wait_step("wait_11m", "Attendre 11 mois",
delay_days=330, depends_on=["activate_sub"]),
_issue_step("renewal_ticket",
"Ticket renouvellement",
"Rappel: contrat arrive à échéance dans 1 mois",
depends_on=["wait_11m"]),
],
},
}
def _tpl_quotation_follow_up():
"""Relance douce 48h après envoi de quotation non acceptée."""
return {
"template_name": "Relance quotation non signée",
"category": "Custom",
"applies_to": "Quotation",
"icon": "mail",
"description": "SMS de rappel 48h après envoi d'un devis résidentiel",
"is_system": 1,
"trigger_event": "on_quotation_created",
"trigger_condition": "quotation.status == 'Submitted'",
"flow_definition": {
"version": 1,
"trigger": {
"event": "on_quotation_created",
"condition": "quotation.status == 'Submitted'",
},
"variables": {},
"steps": [
_wait_step("wait_48h", "Attendre 48h", delay_hours=48),
_notify_step("reminder_sms",
"SMS rappel devis", channel="sms",
template_id="quotation_reminder",
depends_on=["wait_48h"]),
],
},
}
# ---------------------------------------------------------------------------
# Seeder
# ---------------------------------------------------------------------------
SEEDS = [
_tpl_fiber_install,
_tpl_phone_service,
_tpl_move_service,
_tpl_repair_service,
_tpl_residential_onboarding,
_tpl_quotation_follow_up,
]
def _count_steps(flow_def):
return len(flow_def.get("steps", []))
def _upsert(tpl):
name = tpl["template_name"]
exists = frappe.db.exists("Flow Template", {"template_name": name})
flow_def_json = json.dumps(tpl["flow_definition"], ensure_ascii=False, indent=2)
step_count = _count_steps(tpl["flow_definition"])
if exists:
doc = frappe.get_doc("Flow Template", exists)
# Only update is_system=1 seeds automatically
if not doc.is_system:
print(f" SKIP {name!r} — exists as user-edited (is_system=0)")
return
doc.update({
"category": tpl["category"],
"applies_to": tpl["applies_to"],
"icon": tpl.get("icon", "account_tree"),
"description": tpl.get("description", ""),
"trigger_event": tpl.get("trigger_event", "manual"),
"trigger_condition": tpl.get("trigger_condition", ""),
"flow_definition": flow_def_json,
"step_count": step_count,
"version": (doc.version or 0) + 1,
})
doc.save(ignore_permissions=True)
print(f" UPDATED {name!r} -> v{doc.version}, {step_count} steps")
return
doc = frappe.get_doc({
"doctype": "Flow Template",
"template_name": name,
"category": tpl["category"],
"applies_to": tpl["applies_to"],
"icon": tpl.get("icon", "account_tree"),
"is_active": 1,
"is_system": tpl.get("is_system", 0),
"version": 1,
"description": tpl.get("description", ""),
"trigger_event": tpl.get("trigger_event", "manual"),
"trigger_condition": tpl.get("trigger_condition", ""),
"flow_definition": flow_def_json,
"step_count": step_count,
})
doc.insert(ignore_permissions=True)
print(f" CREATED {name!r} -> {doc.name}, {step_count} steps")
def seed_all():
for factory in SEEDS:
tpl = factory()
_upsert(tpl)
frappe.db.commit()
print(f"\n[OK] Seeded {len(SEEDS)} Flow Templates.")
if __name__ == "__main__":
seed_all()