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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-27 14:02:25 -04:00
commit 49494cf1a7
4 changed files with 663 additions and 0 deletions

27
README.md Normal file
View File

@ -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 |

135
docs/ARCHITECTURE.md Normal file
View File

@ -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 |

44
docs/ROADMAP.md Normal file
View File

@ -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

View File

@ -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 <site-name> 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.")