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>
460 lines
17 KiB
Python
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()
|