From 49494cf1a7009d88a49b72dfda4256b778bba212 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 27 Mar 2026 14:02:25 -0400 Subject: [PATCH] Initial commit: FSM data model, architecture docs, setup scripts Data model inspired by Odoo OCA Field Service + Salesforce FS patterns. Adapted for small ISP/telecom (Gigafibre) running ERPNext. Doctypes: Service Location, Service Equipment, Service Subscription + child tables for equipment history, checklists, photos, materials + extended Dispatch Job with customer/location/equipment links Docs: architecture overview, tech stack, auth flow, industry comparison, roadmap Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 27 ++ docs/ARCHITECTURE.md | 135 ++++++++++ docs/ROADMAP.md | 44 ++++ scripts/setup_fsm_doctypes.py | 457 ++++++++++++++++++++++++++++++++++ 4 files changed, 663 insertions(+) create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ROADMAP.md create mode 100644 scripts/setup_fsm_doctypes.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..acd9dd7 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Gigafibre FSM + +Field Service Management for Gigafibre ISP — built on ERPNext + Vue/Quasar. + +## Quick Start + +### 1. Create ERPNext doctypes +```bash +# Copy script to ERPNext container +docker cp scripts/setup_fsm_doctypes.py erpnext-backend-1:/home/frappe/frappe-bench/apps/frappe/frappe/ + +# Execute +docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute frappe.setup_fsm_doctypes.create_all +``` + +### 2. Dispatch PWA +See [OSS-BSS-Field-Dispatch](https://git.targo.ca/louis/OSS-BSS-Field-Dispatch) repo. + +## Documentation +- [Architecture](docs/ARCHITECTURE.md) — data model, tech stack, auth flow +- [Roadmap](docs/ROADMAP.md) — phased implementation plan + +## Related Repos +| Repo | Purpose | +|------|---------| +| [OSS-BSS-Field-Dispatch](https://git.targo.ca/louis/OSS-BSS-Field-Dispatch) | Vue/Quasar dispatch PWA | +| [frappe_docker](https://git.targo.ca/louis/frappe-docker) | ERPNext Docker setup | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..82c5c61 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,135 @@ +# Gigafibre FSM — Architecture + +## Overview + +Field Service Management platform for Gigafibre ISP. +Inspired by Odoo OCA Field Service, Salesforce Field Service, and Zuper. + +## Data Model + +``` +Customer (ERPNext native) + └─ Service Location (LOC-#####) + ├─ Address + GPS coordinates + ├─ Connection type (FTTH/FTTB/Cable/DSL) + ├─ OLT port, VLAN, network config + ├─ Access notes (door code, dog, etc.) + │ + ├─ Service Equipment (EQP-#####) + │ ├─ Type: ONT / Modem / Router / TV Box / IP Phone + │ ├─ Serial number + MAC address + │ ├─ Status: inventory → active → defective → returned + │ ├─ Network config (IP, firmware, credentials) + │ ├─ Move history (Equipment Move Log) + │ └─ Linked to Subscription + │ + └─ Service Subscription (SUB-#####) + ├─ Category: Internet / IPTV / VoIP / Bundle + ├─ Plan: Fibre 50, Fibre 100, IPTV Essentiel... + ├─ Speed up/down, monthly price, billing cycle + ├─ Contract duration, promo end date + └─ Status: pending → active → suspended → cancelled + +Dispatch Job (existing, extended) + ├─ Customer + Service Location links + ├─ Job type: Installation / Repair / Maintenance / Removal + ├─ Source: Helpdesk Issue or direct creation + ├─ Assigned tech + assistants + ├─ Schedule: date, time, duration, route order + ├─ Equipment Items (installed/removed/replaced) + ├─ Materials Used (from inventory) + ├─ Checklist (configurable per job type) + ├─ Time tracking (actual start/end, travel time) + ├─ Photos + Customer signature + └─ GPS position (via Traccar WebSocket) +``` + +## Tech Stack + +| Component | Technology | Location | +|-----------|-----------|----------| +| ERP Backend | ERPNext v16 (Frappe) | erp.gigafibre.ca | +| Dispatch PWA | Vue 3 / Quasar / Pinia | dispatch.gigafibre.ca | +| GPS Tracking | Traccar (WebSocket + REST) | tracker.targointernet.com | +| Maps & Routes | Mapbox GL JS + Directions API | Client-side | +| Auth / SSO | Authentik (forwardAuth) | auth.targo.ca | +| Reverse Proxy | Traefik v2.11 | Port 80/443 | +| Workflows | n8n | n8n.gigafibre.ca | +| Admin Hub | Custom Node.js | hub.gigafibre.ca | + +## Authentication Flow + +``` +User → dispatch.gigafibre.ca + → Traefik (authentik@file middleware) + → Authentik forwardAuth check + → Valid session? → App loads + → No session? → Redirect auth.targo.ca → Login → Callback → App + +API calls: /api/* → ERPNext (service token, no CORS) +GPS data: /traccar/* → Traccar proxy (Basic auth) +``` + +## ERPNext Doctypes (module: Dispatch) + +### Core +| Doctype | Autoname | Purpose | +|---------|----------|---------| +| Dispatch Technician | field:technician_id | Tech profile + GPS link | +| Dispatch Job | field:ticket_id | Work orders | +| Dispatch Tag | field:name | Job categorization | + +### FSM Extension +| Doctype | Autoname | Purpose | +|---------|----------|---------| +| Service Location | LOC-.##### | Customer premises | +| Service Equipment | EQP-.##### | Deployed hardware | +| Service Subscription | SUB-.##### | Active service plans | +| Checklist Template | field:template_name | Reusable checklists | + +### Child Tables +| Doctype | Parent | +|---------|--------| +| Equipment Move Log | Service Equipment | +| Job Equipment Item | Dispatch Job | +| Job Material Used | Dispatch Job | +| Job Checklist Item | Dispatch Job | +| Job Photo | Dispatch Job | +| Checklist Template Item | Checklist Template | +| Dispatch Job Assistant | Dispatch Job | +| Dispatch Tag Link | Dispatch Job / Technician | + +## Workflows + +### Helpdesk → Dispatch +1. Issue created (ERPNext HD) with type "Field Service" +2. Server script creates Dispatch Job, links back to Issue +3. Dispatcher assigns tech via PWA timeline +4. Tech completes job → Issue auto-resolved + +### Equipment Lifecycle +1. Equipment purchased → status "En inventaire" +2. Installation job → status "Actif", linked to location + subscription +3. Repair/replacement → move log entry, status update +4. Return → status "Retourné", unlinked from location + +### Subscription Lifecycle +1. Customer signs up → Subscription "En attente" +2. Installation job completed → Subscription "Actif" +3. Non-payment → Subscription "Suspendu" +4. Cancellation → removal job → equipment returned → Subscription "Annulé" + +## Comparison with Industry Tools + +| Feature | Gigafibre FSM | Odoo FS | Zuper | Salesforce FS | +|---------|---------------|---------|-------|---------------| +| Drag-drop dispatch | Yes (custom PWA) | Yes | Yes | Yes | +| GPS real-time | Yes (Traccar WS) | Limited | Yes | Yes | +| Route optimization | Yes (Mapbox) | Basic | Yes | Advanced | +| Equipment tracking | Yes (serial+MAC) | Yes | Yes | Yes | +| Barcode scanning | Planned | Yes | Yes | Yes | +| Offline mobile | Planned (PWA) | Limited | Yes | Yes | +| Subscriptions | Yes (custom) | Yes (native) | Yes | Yes | +| Helpdesk integration | Yes (ERPNext Issue) | Yes | Yes (Zendesk) | Yes (native) | +| Self-hosted | Yes | Yes | No | No | +| Cost | Free (OSS) | Free (CE) | ~$50/user/mo | ~$200/user/mo | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..c1c2120 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,44 @@ +# Gigafibre FSM — Roadmap + +## Phase 1 — Foundation (Done) +- [x] Dispatch PWA with timeline, drag-drop, map +- [x] GPS tracking (Traccar hybrid REST + WebSocket) +- [x] Tech CRUD in GPS modal +- [x] Authentik SSO (forwardAuth) for all apps +- [x] ERPNext API proxy (same-origin) +- [x] FSM doctypes: Service Location, Equipment, Subscription + +## Phase 2 — PWA Integration +- [ ] Customer/Location picker in job creation modal +- [ ] Equipment scanner (barcode via camera API) +- [ ] Checklist UI on job detail panel +- [ ] Photo capture with annotations +- [ ] Customer signature pad (HTML Canvas) +- [ ] Time tracking (start/pause/stop on job) +- [ ] Offline-first with IndexedDB sync + +## Phase 3 — Workflows & Automation +- [ ] Issue → Dispatch Job (server script) +- [ ] Job completion → equipment status update +- [ ] Job completion → close helpdesk ticket +- [ ] Equipment swap → inventory move log +- [ ] Twilio SMS notifications (tech + customer) +- [ ] n8n workflows for escalation rules +- [ ] SLA tracking on subscriptions + +## Phase 4 — Customer Portal +- [ ] Customer-facing web app (service status) +- [ ] Online appointment booking +- [ ] Real-time tech tracking ("On my way" SMS) +- [ ] Invoice/payment history +- [ ] Equipment list per location +- [ ] Service request submission + +## Phase 5 — Advanced Features +- [ ] Van stock inventory per technician +- [ ] Part usage → auto-reorder +- [ ] Multi-day project tracking (fiber builds) +- [ ] Tech performance dashboards +- [ ] Revenue analytics (MRR, churn, ARPU) +- [ ] Preventive maintenance scheduling +- [ ] White-label mobile app for techs diff --git a/scripts/setup_fsm_doctypes.py b/scripts/setup_fsm_doctypes.py new file mode 100644 index 0000000..87d6b61 --- /dev/null +++ b/scripts/setup_fsm_doctypes.py @@ -0,0 +1,457 @@ +""" +setup_fsm_doctypes.py — Create Field Service Management doctypes for Gigafibre. + +Inspired by Odoo OCA Field Service (fsm.location, fsm.equipment, fsm.order) +and adapted for Frappe/ERPNext with ISP/telecom-specific fields. + +Run inside the bench container: + bench --site execute setup_fsm_doctypes.create_all + +Or via docker: + docker compose exec backend bench --site frontend execute setup_fsm_doctypes.create_all +""" + +import frappe + + +def create_all(): + _create_service_location() + _create_service_subscription() + _create_equipment_move_log() + _create_service_equipment() + _create_job_equipment_item() + _create_job_material_used() + _create_job_checklist_item() + _create_job_photo() + _create_checklist_template() + _extend_dispatch_job() + frappe.db.commit() + print("✓ FSM doctypes created successfully.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Service Location — Customer premises where service is delivered +# ───────────────────────────────────────────────────────────────────────────── + +def _create_service_location(): + if frappe.db.exists("DocType", "Service Location"): + print(" Service Location already exists — skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Service Location", + "module": "Dispatch", + "custom": 1, + "autoname": "LOC-.#####", + "track_changes": 1, + "fields": [ + # ── Client ────────────────────────────────────────────────────── + {"fieldname": "customer", "fieldtype": "Link", "label": "Client", + "options": "Customer", "reqd": 1, "in_list_view": 1}, + {"fieldname": "customer_name", "fieldtype": "Data", "label": "Nom du client", + "fetch_from": "customer.customer_name", "read_only": 1}, + {"fieldname": "col_loc1", "fieldtype": "Column Break"}, + {"fieldname": "location_name", "fieldtype": "Data", "label": "Nom du lieu", + "in_list_view": 1}, + {"fieldname": "status", "fieldtype": "Select", "label": "Statut", + "options": "Active\nInactive\nPending Install", + "default": "Pending Install", "in_list_view": 1}, + + # ── Adresse ───────────────────────────────────────────────────── + {"fieldname": "sec_address", "fieldtype": "Section Break", "label": "Adresse"}, + {"fieldname": "address_line", "fieldtype": "Small Text", "label": "Adresse", "reqd": 1}, + {"fieldname": "city", "fieldtype": "Data", "label": "Ville"}, + {"fieldname": "postal_code", "fieldtype": "Data", "label": "Code postal"}, + {"fieldname": "province", "fieldtype": "Data", "label": "Province", "default": "QC"}, + {"fieldname": "col_geo", "fieldtype": "Column Break"}, + {"fieldname": "longitude", "fieldtype": "Float", "label": "Longitude", "precision": "7"}, + {"fieldname": "latitude", "fieldtype": "Float", "label": "Latitude", "precision": "7"}, + + # ── Infra technique ────────────────────────────────────────────── + {"fieldname": "sec_infra", "fieldtype": "Section Break", "label": "Infrastructure technique"}, + {"fieldname": "connection_type", "fieldtype": "Select", "label": "Type de raccordement", + "options": "\nFibre FTTH\nFibre FTTB\nCable coaxial\nDSL\nSans-fil\nAutre"}, + {"fieldname": "olt_port", "fieldtype": "Data", "label": "Port OLT"}, + {"fieldname": "col_infra", "fieldtype": "Column Break"}, + {"fieldname": "network_id", "fieldtype": "Data", "label": "ID réseau (VLAN/PPPoE)"}, + {"fieldname": "network_notes", "fieldtype": "Small Text", "label": "Notes réseau"}, + + # ── Contact sur place ──────────────────────────────────────────── + {"fieldname": "sec_contact", "fieldtype": "Section Break", "label": "Contact sur place"}, + {"fieldname": "contact_name", "fieldtype": "Data", "label": "Nom contact"}, + {"fieldname": "contact_phone", "fieldtype": "Data", "label": "Téléphone"}, + {"fieldname": "col_access", "fieldtype": "Column Break"}, + {"fieldname": "access_notes", "fieldtype": "Small Text", "label": "Notes d'accès", + "description": "Code porte, chien, instructions spéciales..."}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "All", "read": 1, "write": 1, "create": 1, "delete": 1}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Service Location created.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Equipment Move Log — Child table for equipment movement history +# ───────────────────────────────────────────────────────────────────────────── + +def _create_equipment_move_log(): + if frappe.db.exists("DocType", "Equipment Move Log"): + print(" Equipment Move Log already exists — skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Equipment Move Log", + "module": "Dispatch", + "custom": 1, + "istable": 1, + "fields": [ + {"fieldname": "move_date", "fieldtype": "Date", "label": "Date", "default": "Today", "in_list_view": 1}, + {"fieldname": "from_location", "fieldtype": "Link", "label": "De", "options": "Service Location", "in_list_view": 1}, + {"fieldname": "to_location", "fieldtype": "Link", "label": "Vers", "options": "Service Location", "in_list_view": 1}, + {"fieldname": "reason", "fieldtype": "Select", "label": "Raison", + "options": "Installation\nRemplacement\nRéparation\nRetour\nTransfert", "in_list_view": 1}, + {"fieldname": "technician", "fieldtype": "Data", "label": "Technicien"}, + {"fieldname": "dispatch_job", "fieldtype": "Link", "label": "Job", "options": "Dispatch Job"}, + {"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Equipment Move Log created.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Service Equipment — Physical devices at customer premises +# ───────────────────────────────────────────────────────────────────────────── + +def _create_service_equipment(): + if frappe.db.exists("DocType", "Service Equipment"): + print(" Service Equipment already exists — skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Service Equipment", + "module": "Dispatch", + "custom": 1, + "autoname": "EQP-.#####", + "track_changes": 1, + "fields": [ + # ── Identification ─────────────────────────────────────────────── + {"fieldname": "equipment_type", "fieldtype": "Select", "label": "Type", + "options": "ONT\nModem\nRouteur\nDécodeur TV\nTéléphone IP\nSwitch\nAmplificateur\nAP WiFi\nCâble/Connecteur\nAutre", + "reqd": 1, "in_list_view": 1}, + {"fieldname": "brand", "fieldtype": "Data", "label": "Marque"}, + {"fieldname": "model", "fieldtype": "Data", "label": "Modèle"}, + {"fieldname": "col_id", "fieldtype": "Column Break"}, + {"fieldname": "serial_number", "fieldtype": "Data", "label": "Numéro de série", + "reqd": 1, "unique": 1, "in_list_view": 1}, + {"fieldname": "mac_address", "fieldtype": "Data", "label": "Adresse MAC"}, + {"fieldname": "barcode", "fieldtype": "Data", "label": "Code-barres"}, + + # ── Emplacement ────────────────────────────────────────────────── + {"fieldname": "sec_location", "fieldtype": "Section Break", "label": "Emplacement"}, + {"fieldname": "customer", "fieldtype": "Link", "label": "Client", "options": "Customer", "in_list_view": 1}, + {"fieldname": "service_location", "fieldtype": "Link", "label": "Lieu de service", + "options": "Service Location"}, + {"fieldname": "col_status", "fieldtype": "Column Break"}, + {"fieldname": "status", "fieldtype": "Select", "label": "Statut", + "options": "En inventaire\nActif\nDéfectueux\nRetourné\nPerdu", + "default": "En inventaire", "in_list_view": 1}, + {"fieldname": "ownership", "fieldtype": "Select", "label": "Propriété", + "options": "Gigafibre\nClient", "default": "Gigafibre"}, + + # ── Abonnement lié ─────────────────────────────────────────────── + {"fieldname": "sec_sub", "fieldtype": "Section Break", "label": "Abonnement"}, + {"fieldname": "subscription", "fieldtype": "Link", "label": "Abonnement", + "options": "Service Subscription"}, + + # ── Dates ──────────────────────────────────────────────────────── + {"fieldname": "sec_dates", "fieldtype": "Section Break", "label": "Dates"}, + {"fieldname": "purchase_date", "fieldtype": "Date", "label": "Date d'achat"}, + {"fieldname": "installation_date", "fieldtype": "Date", "label": "Date d'installation"}, + {"fieldname": "col_dates", "fieldtype": "Column Break"}, + {"fieldname": "warranty_end", "fieldtype": "Date", "label": "Fin garantie"}, + + # ── Configuration réseau ───────────────────────────────────────── + {"fieldname": "sec_config", "fieldtype": "Section Break", "label": "Configuration"}, + {"fieldname": "ip_address", "fieldtype": "Data", "label": "Adresse IP"}, + {"fieldname": "firmware_version", "fieldtype": "Data", "label": "Version firmware"}, + {"fieldname": "col_config", "fieldtype": "Column Break"}, + {"fieldname": "login_user", "fieldtype": "Data", "label": "Utilisateur"}, + {"fieldname": "login_password", "fieldtype": "Password", "label": "Mot de passe"}, + + # ── Historique ─────────────────────────────────────────────────── + {"fieldname": "sec_history", "fieldtype": "Section Break", "label": "Historique des déplacements"}, + {"fieldname": "move_log", "fieldtype": "Table", "label": "Mouvements", + "options": "Equipment Move Log"}, + + # ── Notes ──────────────────────────────────────────────────────── + {"fieldname": "sec_notes", "fieldtype": "Section Break", "label": "Notes"}, + {"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"}, + {"fieldname": "photo", "fieldtype": "Attach Image", "label": "Photo"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "All", "read": 1, "write": 1, "create": 1, "delete": 1}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Service Equipment created.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Service Subscription — Active service plans at locations +# ───────────────────────────────────────────────────────────────────────────── + +def _create_service_subscription(): + if frappe.db.exists("DocType", "Service Subscription"): + print(" Service Subscription already exists — skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", + "name": "Service Subscription", + "module": "Dispatch", + "custom": 1, + "autoname": "SUB-.#####", + "track_changes": 1, + "fields": [ + # ── Client ────────────────────────────────────────────────────── + {"fieldname": "customer", "fieldtype": "Link", "label": "Client", + "options": "Customer", "reqd": 1, "in_list_view": 1}, + {"fieldname": "customer_name", "fieldtype": "Data", "label": "Nom du client", + "fetch_from": "customer.customer_name", "read_only": 1}, + {"fieldname": "service_location", "fieldtype": "Link", "label": "Lieu de service", + "options": "Service Location", "reqd": 1}, + {"fieldname": "col_sub1", "fieldtype": "Column Break"}, + {"fieldname": "status", "fieldtype": "Select", "label": "Statut", + "options": "Actif\nSuspendu\nAnnulé\nEn attente", + "default": "En attente", "in_list_view": 1}, + + # ── Forfait ────────────────────────────────────────────────────── + {"fieldname": "sec_plan", "fieldtype": "Section Break", "label": "Forfait"}, + {"fieldname": "service_category", "fieldtype": "Select", "label": "Catégorie", + "options": "Internet\nIPTV\nVoIP\nBundle\nHébergement\nAutre", + "reqd": 1, "in_list_view": 1}, + {"fieldname": "plan_name", "fieldtype": "Data", "label": "Nom du forfait", + "in_list_view": 1}, + {"fieldname": "col_plan", "fieldtype": "Column Break"}, + {"fieldname": "speed_down", "fieldtype": "Int", "label": "Débit ↓ (Mbps)"}, + {"fieldname": "speed_up", "fieldtype": "Int", "label": "Débit ↑ (Mbps)"}, + + # ── Tarification ───────────────────────────────────────────────── + {"fieldname": "sec_price", "fieldtype": "Section Break", "label": "Tarification"}, + {"fieldname": "monthly_price", "fieldtype": "Currency", "label": "Prix mensuel"}, + {"fieldname": "billing_cycle", "fieldtype": "Select", "label": "Cycle de facturation", + "options": "Mensuel\nTrimestriel\nAnnuel", "default": "Mensuel"}, + {"fieldname": "col_price", "fieldtype": "Column Break"}, + {"fieldname": "contract_duration", "fieldtype": "Int", "label": "Durée contrat (mois)"}, + {"fieldname": "promo_end", "fieldtype": "Date", "label": "Fin promotion"}, + + # ── Dates ──────────────────────────────────────────────────────── + {"fieldname": "sec_subdates", "fieldtype": "Section Break", "label": "Dates"}, + {"fieldname": "start_date", "fieldtype": "Date", "label": "Début", "reqd": 1}, + {"fieldname": "end_date", "fieldtype": "Date", "label": "Fin"}, + {"fieldname": "col_subdates", "fieldtype": "Column Break"}, + {"fieldname": "cancellation_date", "fieldtype": "Date", "label": "Date d'annulation"}, + {"fieldname": "cancellation_reason", "fieldtype": "Small Text", "label": "Raison annulation"}, + + # ── Notes ──────────────────────────────────────────────────────── + {"fieldname": "sec_subnotes", "fieldtype": "Section Break", "label": "Notes"}, + {"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + {"role": "All", "read": 1, "write": 1, "create": 1, "delete": 1}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Service Subscription created.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Child tables for enhanced Dispatch Job +# ───────────────────────────────────────────────────────────────────────────── + +def _create_job_equipment_item(): + if frappe.db.exists("DocType", "Job Equipment Item"): + print(" Job Equipment Item already exists — skipping.") + return + doc = frappe.get_doc({ + "doctype": "DocType", "name": "Job Equipment Item", + "module": "Dispatch", "custom": 1, "istable": 1, + "fields": [ + {"fieldname": "equipment", "fieldtype": "Link", "label": "Équipement", + "options": "Service Equipment", "in_list_view": 1}, + {"fieldname": "serial_number", "fieldtype": "Data", "label": "N/S", + "fetch_from": "equipment.serial_number", "read_only": 1, "in_list_view": 1}, + {"fieldname": "equipment_type", "fieldtype": "Data", "label": "Type", + "fetch_from": "equipment.equipment_type", "read_only": 1, "in_list_view": 1}, + {"fieldname": "action", "fieldtype": "Select", "label": "Action", + "options": "Installé\nRetiré\nRemplacé\nDiagnostiqué\nConfiguré", "in_list_view": 1}, + {"fieldname": "notes", "fieldtype": "Data", "label": "Notes"}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Job Equipment Item created.") + + +def _create_job_material_used(): + if frappe.db.exists("DocType", "Job Material Used"): + print(" Job Material Used already exists — skipping.") + return + doc = frappe.get_doc({ + "doctype": "DocType", "name": "Job Material Used", + "module": "Dispatch", "custom": 1, "istable": 1, + "fields": [ + {"fieldname": "item_name", "fieldtype": "Data", "label": "Article", "reqd": 1, "in_list_view": 1}, + {"fieldname": "quantity", "fieldtype": "Float", "label": "Quantité", "default": "1", "in_list_view": 1}, + {"fieldname": "unit", "fieldtype": "Data", "label": "Unité", "default": "pcs"}, + {"fieldname": "notes", "fieldtype": "Data", "label": "Notes", "in_list_view": 1}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Job Material Used created.") + + +def _create_job_checklist_item(): + if frappe.db.exists("DocType", "Job Checklist Item"): + print(" Job Checklist Item already exists — skipping.") + return + doc = frappe.get_doc({ + "doctype": "DocType", "name": "Job Checklist Item", + "module": "Dispatch", "custom": 1, "istable": 1, + "fields": [ + {"fieldname": "task", "fieldtype": "Data", "label": "Tâche", "reqd": 1, "in_list_view": 1}, + {"fieldname": "is_done", "fieldtype": "Check", "label": "Fait", "in_list_view": 1}, + {"fieldname": "notes", "fieldtype": "Data", "label": "Notes", "in_list_view": 1}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Job Checklist Item created.") + + +def _create_job_photo(): + if frappe.db.exists("DocType", "Job Photo"): + print(" Job Photo already exists — skipping.") + return + doc = frappe.get_doc({ + "doctype": "DocType", "name": "Job Photo", + "module": "Dispatch", "custom": 1, "istable": 1, + "fields": [ + {"fieldname": "photo", "fieldtype": "Attach Image", "label": "Photo"}, + {"fieldname": "caption", "fieldtype": "Data", "label": "Légende", "in_list_view": 1}, + {"fieldname": "taken_at", "fieldtype": "Datetime", "label": "Prise le"}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Job Photo created.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Checklist Template — Default checklists per job type +# ───────────────────────────────────────────────────────────────────────────── + +def _create_checklist_template(): + # Child table for template items + if not frappe.db.exists("DocType", "Checklist Template Item"): + frappe.get_doc({ + "doctype": "DocType", "name": "Checklist Template Item", + "module": "Dispatch", "custom": 1, "istable": 1, + "fields": [ + {"fieldname": "task", "fieldtype": "Data", "label": "Tâche", "reqd": 1, "in_list_view": 1}, + {"fieldname": "sort_order", "fieldtype": "Int", "label": "Ordre", "in_list_view": 1}, + ], + }).insert(ignore_permissions=True) + print(" ✓ Checklist Template Item created.") + + if frappe.db.exists("DocType", "Checklist Template"): + print(" Checklist Template already exists — skipping.") + return + + doc = frappe.get_doc({ + "doctype": "DocType", "name": "Checklist Template", + "module": "Dispatch", "custom": 1, + "autoname": "field:template_name", + "fields": [ + {"fieldname": "template_name", "fieldtype": "Data", "label": "Nom", "reqd": 1, "unique": 1}, + {"fieldname": "job_type", "fieldtype": "Select", "label": "Type de job", + "options": "Installation\nRéparation\nMaintenance\nRetrait\nDépannage\nAutre"}, + {"fieldname": "items", "fieldtype": "Table", "label": "Tâches", + "options": "Checklist Template Item"}, + ], + "permissions": [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1}, + ], + }) + doc.insert(ignore_permissions=True) + print(" ✓ Checklist Template created.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Extend Dispatch Job — Add FSM fields to existing doctype +# ───────────────────────────────────────────────────────────────────────────── + +def _extend_dispatch_job(): + """Add custom fields to the existing Dispatch Job doctype.""" + new_fields = [ + # Client & Lieu + {"dt": "Dispatch Job", "fieldname": "customer", "fieldtype": "Link", + "label": "Client", "options": "Customer", "insert_after": "subject"}, + {"dt": "Dispatch Job", "fieldname": "service_location", "fieldtype": "Link", + "label": "Lieu de service", "options": "Service Location", "insert_after": "customer"}, + # Type + {"dt": "Dispatch Job", "fieldname": "job_type", "fieldtype": "Select", + "label": "Type de travail", + "options": "\nInstallation\nRéparation\nMaintenance\nRetrait\nDépannage\nAutre", + "insert_after": "service_location"}, + {"dt": "Dispatch Job", "fieldname": "source_issue", "fieldtype": "Link", + "label": "Ticket source", "options": "Issue", "insert_after": "job_type"}, + # Equipment + {"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break", + "label": "Équipements", "insert_after": "tags"}, + {"dt": "Dispatch Job", "fieldname": "equipment_items", "fieldtype": "Table", + "label": "Équipements", "options": "Job Equipment Item", "insert_after": "sec_equipment"}, + # Materials + {"dt": "Dispatch Job", "fieldname": "sec_materials", "fieldtype": "Section Break", + "label": "Matériaux utilisés", "insert_after": "equipment_items"}, + {"dt": "Dispatch Job", "fieldname": "materials_used", "fieldtype": "Table", + "label": "Matériaux", "options": "Job Material Used", "insert_after": "sec_materials"}, + # Time tracking + {"dt": "Dispatch Job", "fieldname": "sec_time", "fieldtype": "Section Break", + "label": "Suivi temps", "insert_after": "materials_used"}, + {"dt": "Dispatch Job", "fieldname": "actual_start", "fieldtype": "Datetime", + "label": "Début réel", "insert_after": "sec_time"}, + {"dt": "Dispatch Job", "fieldname": "actual_end", "fieldtype": "Datetime", + "label": "Fin réelle", "insert_after": "actual_start"}, + {"dt": "Dispatch Job", "fieldname": "travel_time_min", "fieldtype": "Int", + "label": "Temps trajet (min)", "insert_after": "actual_end"}, + # Checklist + {"dt": "Dispatch Job", "fieldname": "sec_checklist", "fieldtype": "Section Break", + "label": "Checklist", "insert_after": "travel_time_min"}, + {"dt": "Dispatch Job", "fieldname": "checklist", "fieldtype": "Table", + "label": "Checklist", "options": "Job Checklist Item", "insert_after": "sec_checklist"}, + # Completion + {"dt": "Dispatch Job", "fieldname": "sec_completion", "fieldtype": "Section Break", + "label": "Complétion", "insert_after": "checklist"}, + {"dt": "Dispatch Job", "fieldname": "completion_notes", "fieldtype": "Text", + "label": "Notes de complétion", "insert_after": "sec_completion"}, + {"dt": "Dispatch Job", "fieldname": "customer_signature", "fieldtype": "Attach Image", + "label": "Signature client", "insert_after": "completion_notes"}, + {"dt": "Dispatch Job", "fieldname": "photos", "fieldtype": "Table", + "label": "Photos", "options": "Job Photo", "insert_after": "customer_signature"}, + ] + + for field_def in new_fields: + fieldname = field_def["fieldname"] + if frappe.db.exists("Custom Field", {"dt": "Dispatch Job", "fieldname": fieldname}): + continue + frappe.get_doc({"doctype": "Custom Field", **field_def}).insert(ignore_permissions=True) + print(f" + Dispatch Job.{fieldname}") + + print(" ✓ Dispatch Job extended with FSM fields.")