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:
commit
49494cf1a7
27
README.md
Normal file
27
README.md
Normal 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
135
docs/ARCHITECTURE.md
Normal 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
44
docs/ROADMAP.md
Normal 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
|
||||||
457
scripts/setup_fsm_doctypes.py
Normal file
457
scripts/setup_fsm_doctypes.py
Normal 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.")
|
||||||
Loading…
Reference in New Issue
Block a user