Compare commits

...

19 Commits

Author SHA1 Message Date
louispaulb
8a42660284 feat: champ Dispatch Job.legacy_detail (description ticket legacy, pont)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:50:23 -04:00
louispaulb
f702ab5811 feat: champ Dispatch Job.legacy_activation_url (lien activation STB Ministra, pont Phase 1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:20:11 -04:00
louispaulb
b05a581359 feat: champ Dispatch Job.legacy_dept (coloriage cartes par type, pont legacy→dispatch)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:05:46 -04:00
louispaulb
d39dde3a9d feat: champ Dispatch Job.legacy_ticket_id (idempotence du pont legacy→dispatch)
Renseigné par services/targo-hub/lib/legacy-dispatch-sync.js (repo gigafibre-fsm) :
1 ticket osTicket legacy = 1 Dispatch Job. read_only + search_index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:34:18 -04:00
louispaulb
7007fb3b22 Tech Availability: champ long_term (absence longue durée = à remplacer)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:31:36 -04:00
louispaulb
e10a83edbe Shift Template: champ on_call (quart de garde, non offert au booking)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:38:04 -04:00
louispaulb
50bdd7c981 Dispatch Job booking_status: ajout option 'À reporter' (file reschedule)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:28:47 -04:00
louispaulb
453cdec77d Item: bouton raccourci '💲 Prix de vente' (Client Script) → ouvre/crée l'Item Price Standard Selling en 1 clic
Contourne l'UX peu intuitive d'ERPNext (prix dans doctype séparé). Variantes → bouton 'Variantes & prix'. Stocké en base (survit aux recreate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:07:50 -04:00
louispaulb
077a1044ed PG patches: ajout #4 repost_item_valuation + #5 general_ledger validate_against_pcv (Period Closing GROUP BY)
5 patchs MySQLisme désormais cuits dans l'image erpnext-custom via Dockerfile (COPY+RUN apply_pg_patches.py).
Débloque submit stock/compta sur PostgreSQL (vérifié: Stock Reconciliation submit OK sur conteneur frais).
frappe_pg aussi proprement bake + ajouté à apps.txt (shim chargé eager pour toutes requêtes, token inclus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:47:33 -04:00
louispaulb
f887beaa14 PG patches reproductibles (idempotent): ORDER BY NULL (product.py) + CurDate (item.py heatmap) + CURRENT_DATE() filet (frappe_pg)
Volatils dans le conteneur → à ré-appliquer après rebuild. Period Closing Voucher GROUP BY = TODO (#53).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:16:09 -04:00
louispaulb
77b22a84ad Store setup: champ store_regular_price (prix barré boutique, inline-editable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:55:59 -04:00
louispaulb
0bdf55e542 Store ERPNext setup (idempotent): champ show_in_store (curation Webstore) + list_view/standard_filter + rapport 'Produits Boutique'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:30:07 -04:00
louispaulb
77190012de Custom Field monthly_regular (Service Contract): prix forfait original barré
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:31:58 -04:00
louispaulb
f4d743036e Custom Field install_regular (Service Contract): prix install barré marketing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:22:23 -04:00
louispaulb
25cb0dff2b setup: champ install_fee sur Service Contract (financement install CRTC 2026-43)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:05:59 -04:00
louispaulb
c3484ca993 Roster/booking: doctypes + custom fields ERPNext (idempotent)
- create_roster_doctypes.py : Shift Template/Requirement/Assignment, Tech Availability
- setup_dispatch_custom_fields.py : efficiency, skills, coût chargé (Dispatch Technician)
  + booking_prefs/status/token (Dispatch Job)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:36 -04:00
louispaulb
f022beb585 refactor: externalize ERP service token via VITE_ERP_TOKEN env var
Token is no longer hardcoded in source — injected at build time.
Build with: VITE_ERP_TOKEN="key:secret" npx quasar build
Prevents accidental token invalidation and keeps secrets out of git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:41 -04:00
louispaulb
3f658ddf55 fix: handle Authentik session expiry in SPA
- authFetch uses redirect:'manual' to detect 302 from Authentik
- If session expired (302/401/opaqueredirect), reload page to trigger
  Traefik forwardAuth → Authentik re-login flow
- Logout redirects to Authentik invalidation flow
- App.vue calls checkSession on mount to populate user identity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:34:39 -04:00
louispaulb
49463c16b4 feat: switch Dispatch auth to Authentik forwardAuth
- Remove login form from App.vue (Authentik handles auth at Traefik level)
- Simplify auth store: no more checkSession/generate_keys complexity
- All ERPNext API calls use a service token (reliable, no CORS issues)
- User identity provided by Authentik X-authentik-email header
- Logout redirects to Authentik end-session URL
- Removed: login(), generate_keys, cookie fallback, token localStorage

Infrastructure:
- Created Authentik Proxy Provider for dispatch.gigafibre.ca
- Added to embedded outpost
- Applied authentik@file middleware to dispatch Traefik router
- Also removed unused Gitea (git.gigafibre.ca) containers + volumes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:33:09 -04:00
8 changed files with 519 additions and 215 deletions

View File

@ -0,0 +1,105 @@
"""
Patchs ERPNext PostgreSQL (MySQLismes) idempotent.
======================================================
ERPNext v16 contient des requêtes SQL propres à MySQL qui cassent sur PostgreSQL.
Le shim `frappe_pg` en traduit certaines, mais (a) il s'applique paresseusement
(hook on_session_creation absent en auth par token) et (b) il a des trous.
On corrige donc À LA SOURCE, de façon idempotente.
Ces patchs modifient le code des apps DANS le conteneur VOLATILES (perdus au
rebuild d'image / recreate). À ré-exécuter après chaque rebuild, idéalement dans
CHAQUE conteneur applicatif (backend, queue-*, scheduler), puis redémarrer.
TODO persistance: cuire dans l'image (overlay) ou via un entrypoint.
Exécution (par conteneur applicatif) :
docker cp apply_pg_patches.py erpnext-backend-1:/tmp/
docker exec erpnext-backend-1 sh -lc \
"cd /home/frappe/frappe-bench && env/bin/python /tmp/apply_pg_patches.py"
docker restart erpnext-backend-1 # recharger les workers gunicorn
Patchs inclus :
1. utilities/product.py `ORDER BY NULL` retiré (validation de variante)
2. stock/.../item.py get_timeline_data : CurDate() add_to_date (heatmap fiche Item)
3. frappe_pg query_transformers CURRENT_DATE()/CURDATE() CURRENT_DATE (filet général)
4. repost_item_valuation.py get_max_period_closing_date : qb Max + ORDER BY creation get_value(order_by=None)
5. accounts/general_ledger.py validate_against_pcv : get_value agrégat MAX + ORDER BY creation
order_by=None. DÉBLOQUE le submit des transactions stock/compta (GroupingError PG).
"""
import re
BENCH = "/home/frappe/frappe-bench/apps"
def patch_file(path, transform, marker):
try:
s = open(path).read()
except FileNotFoundError:
print("ABSENT", path)
return
if marker(s):
print("déjà patché :", path.split("/apps/")[-1])
return
ns = transform(s)
if ns == s:
print("ANCRE INTROUVABLE :", path.split("/apps/")[-1])
return
open(path, "w").write(ns)
print("PATCHED :", path.split("/apps/")[-1])
# 1) product.py — ORDER BY NULL (MySQL) invalide en PG
patch_file(
f"{BENCH}/erpnext/erpnext/utilities/product.py",
lambda s: re.sub(r"ORDER BY\s+NULL", "", s),
lambda s: not re.search(r"ORDER BY\s+NULL", s),
)
# 2) item.py — get_timeline_data : CurDate() - Interval → date calculée en Python
patch_file(
f"{BENCH}/erpnext/erpnext/stock/doctype/item/item.py",
lambda s: s.replace(
".where(sle.posting_date > CurDate() - Interval(years=1))",
".where(sle.posting_date > frappe.utils.add_to_date(frappe.utils.nowdate(), years=-1))",
1,
),
lambda s: "add_to_date(frappe.utils.nowdate(), years=-1)" in s,
)
# 3) frappe_pg — filet général CURRENT_DATE()/CURDATE() → CURRENT_DATE
QT = f"{BENCH}/frappe_pg/frappe_pg/postgres/query_transformers.py"
MARK = "# PG-FIX CURRENT_DATE()"
ADD = (
" " + MARK + "\n"
" import re as _re\n"
" query = _re.sub(r'\\bCURRENT_DATE\\s*\\(\\s*\\)', 'CURRENT_DATE', query, flags=_re.IGNORECASE)\n"
" query = _re.sub(r'\\bCURDATE\\s*\\(\\s*\\)', 'CURRENT_DATE', query, flags=_re.IGNORECASE)\n"
)
patch_file(
QT,
lambda s: s.replace(" query = convert_date_format(query)\n",
" query = convert_date_format(query)\n" + ADD, 1),
lambda s: MARK in s,
)
# 4) repost_item_valuation — get_max_period_closing_date : qb Max(...) + ORDER BY creation
# (GroupingError PG) → get_value(..., order_by=None)
patch_file(
f"{BENCH}/erpnext/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py",
lambda s: re.sub(
r'table = frappe\.qb\.DocType\("Period Closing Voucher"\).*?return query\[0\]\[0\] if query and query\[0\]\[0\] else None',
'return frappe.db.get_value("Period Closing Voucher", {"company": company, "docstatus": 1}, "max(period_end_date)", order_by=None)',
s, count=1, flags=re.DOTALL),
lambda s: 'max(period_end_date)", order_by=None' in s,
)
# 5) general_ledger — validate_against_pcv : get_value avec agrégat MAX + ORDER BY creation par
# défaut (GroupingError PG) → ajout order_by=None. C'est CE patch qui débloque le submit stock/compta.
patch_file(
f"{BENCH}/erpnext/erpnext/accounts/general_ledger.py",
lambda s: s.replace(
'{"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]',
'{"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}], order_by=None', 1),
lambda s: '[{"MAX": "period_end_date"}], order_by=None' in s,
)
print("DONE — redémarrer le conteneur pour recharger les workers.")

View File

@ -0,0 +1,130 @@
"""
Roster / Planification création des DocTypes custom dans ERPNext facturation
==============================================================================
À côté de Dispatch Technician / Dispatch Job. Tout en `custom: 1` (stocké en DB,
aucune modif de fichier d'app, aucun `bench migrate`). ADDITIF : ne touche ni la
facturation ni les flux existants.
DocTypes créés :
- Shift Template : modèles de shifts (heures, couleur, couverture/compétences défaut)
- Shift Requirement : besoin de couverture par date/zone ( « dispo vs requis »)
- Shift Assignment : assignation techdateshift (statut Proposé/Publié, source solveur/manuel)
- Tech Availability : congé / pause / indispo (workflow DemandéApprouvé)
Exécution (depuis le host) :
docker cp create_roster_doctypes.py erpnext-backend-1:/tmp/create_roster_doctypes.py
docker exec -i erpnext-backend-1 bash -lc \
"cd /home/frappe/frappe-bench && bench --site erp.gigafibre.ca console" <<'EOF'
exec(open('/tmp/create_roster_doctypes.py').read())
EOF
"""
import frappe
# Lancer via : cd /home/frappe/frappe-bench && env/bin/python create_roster_doctypes.py
# (vrai module → portée correcte, contrairement à exec() dans la console IPython).
frappe.init(site="erp.gigafibre.ca", sites_path="sites")
frappe.connect()
frappe.flags.ignore_permissions = True
MODULE = "Core"
PERMISSIONS = [
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
{"role": "Administrator", "read": 1, "write": 1, "create": 1, "delete": 1},
]
SHIFT_TEMPLATE_FIELDS = [
{"fieldname": "template_name", "fieldtype": "Data", "label": "Nom du modèle", "reqd": 1, "in_list_view": 1},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "start_time", "fieldtype": "Time", "label": "Heure de début"},
{"fieldname": "end_time", "fieldtype": "Time", "label": "Heure de fin"},
{"fieldname": "hours", "fieldtype": "Float", "label": "Durée (h)", "description": "Heures payées (hors pause)"},
{"fieldname": "sb1", "fieldtype": "Section Break", "label": "Couverture & affichage"},
{"fieldname": "zone", "fieldtype": "Data", "label": "Zone par défaut"},
{"fieldname": "default_required", "fieldtype": "Int", "label": "Effectif requis (défaut)", "default": "1"},
{"fieldname": "required_skills", "fieldtype": "Small Text", "label": "Compétences requises",
"description": "Séparées par des virgules (ex: fibre,cuivre)"},
{"fieldname": "cb2", "fieldtype": "Column Break"},
{"fieldname": "color", "fieldtype": "Data", "label": "Couleur (hex)", "default": "#1976d2"},
{"fieldname": "active", "fieldtype": "Check", "label": "Actif", "default": "1"},
]
SHIFT_REQUIREMENT_FIELDS = [
{"fieldname": "requirement_date", "fieldtype": "Date", "label": "Date", "reqd": 1, "in_list_view": 1},
{"fieldname": "shift_template", "fieldtype": "Link", "options": "Shift Template", "label": "Modèle de shift", "reqd": 1, "in_list_view": 1},
{"fieldname": "zone", "fieldtype": "Data", "label": "Zone", "in_list_view": 1},
{"fieldname": "required_count", "fieldtype": "Int", "label": "Effectif requis", "default": "1", "reqd": 1, "in_list_view": 1},
{"fieldname": "required_skills", "fieldtype": "Small Text", "label": "Compétences requises"},
]
SHIFT_ASSIGNMENT_FIELDS = [
{"fieldname": "technician", "fieldtype": "Data", "label": "Technicien (ID)", "reqd": 1, "in_list_view": 1},
{"fieldname": "technician_name", "fieldtype": "Data", "label": "Nom du technicien", "in_list_view": 1},
{"fieldname": "assignment_date", "fieldtype": "Date", "label": "Date", "reqd": 1, "in_list_view": 1},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "shift_template", "fieldtype": "Link", "options": "Shift Template", "label": "Modèle de shift", "reqd": 1, "in_list_view": 1},
{"fieldname": "zone", "fieldtype": "Data", "label": "Zone"},
{"fieldname": "hours", "fieldtype": "Float", "label": "Heures"},
{"fieldname": "sb1", "fieldtype": "Section Break"},
{"fieldname": "status", "fieldtype": "Select", "options": "Proposé\nPublié\nAnnulé", "default": "Proposé", "label": "Statut", "in_list_view": 1},
{"fieldname": "source", "fieldtype": "Select", "options": "solveur\nmanuel", "default": "solveur", "label": "Source"},
]
TECH_AVAILABILITY_FIELDS = [
{"fieldname": "technician", "fieldtype": "Data", "label": "Technicien (ID)", "reqd": 1, "in_list_view": 1},
{"fieldname": "technician_name", "fieldtype": "Data", "label": "Nom du technicien", "in_list_view": 1},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "availability_type", "fieldtype": "Select", "options": "Congé\nPause\nIndisponible\nMaladie", "default": "Congé", "label": "Type", "in_list_view": 1},
{"fieldname": "status", "fieldtype": "Select", "options": "Demandé\nApprouvé\nRefusé", "default": "Demandé", "label": "Statut", "in_list_view": 1},
{"fieldname": "sb1", "fieldtype": "Section Break"},
{"fieldname": "from_date", "fieldtype": "Date", "label": "Du", "reqd": 1, "in_list_view": 1},
{"fieldname": "to_date", "fieldtype": "Date", "label": "Au", "reqd": 1, "in_list_view": 1},
{"fieldname": "cb2", "fieldtype": "Column Break"},
{"fieldname": "reason", "fieldtype": "Small Text", "label": "Motif"},
{"fieldname": "approver", "fieldtype": "Data", "label": "Approbateur"},
]
DOCTYPES = [
("Shift Template", SHIFT_TEMPLATE_FIELDS, "field:template_name"),
("Shift Requirement", SHIFT_REQUIREMENT_FIELDS, "hash"),
("Shift Assignment", SHIFT_ASSIGNMENT_FIELDS, "hash"),
("Tech Availability", TECH_AVAILABILITY_FIELDS, "hash"),
]
out = []
def log(*a):
out.append(" ".join(str(x) for x in a))
print(*a)
def create_dt(name, fields, autoname):
if frappe.db.exists("DocType", name):
log("EXISTS", name)
return
try:
doc = frappe.new_doc("DocType")
doc.update({
"name": name,
"module": MODULE,
"custom": 1,
"autoname": autoname,
"naming_rule": "Expression" if autoname.startswith("field:") else "Random",
"track_changes": 1,
"fields": fields,
"permissions": PERMISSIONS,
})
doc.insert(ignore_permissions=True)
frappe.db.commit()
log("CREATED", name)
except Exception as e:
frappe.db.rollback()
log("ERROR", name, "->", str(e)[:200])
for name, fields, autoname in DOCTYPES:
create_dt(name, fields, autoname)
open("/tmp/roster_out.txt", "w").write("\n".join(out))
log("DONE")

View File

@ -0,0 +1,83 @@
"""
Custom Fields pour la planification (Roster AI) + prise de RDV idempotent.
==========================================================================
Consolide tous les champs ajoutés à Dispatch Technician et Dispatch Job pour
le module Roster/Planification et le booking. Remplace les scripts épars.
Exécution :
docker cp setup_dispatch_custom_fields.py erpnext-backend-1:/tmp/
docker exec erpnext-backend-1 sh -lc \
"cd /home/frappe/frappe-bench && env/bin/python /tmp/setup_dispatch_custom_fields.py"
(créer d'abord /home/frappe/logs si frappe.init s'en plaint cf.
reference_frappe_hr_postgres.md §Pièges scripts)
"""
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path="sites")
frappe.connect()
frappe.flags.ignore_permissions = True
# (dt, fieldname, label, fieldtype, options, default, insert_after, extra)
FIELDS = [
# ── Dispatch Technician : cadence, coût chargé, compétences ──
("Dispatch Technician", "efficiency", "Cadence (facteur temps)", "Float", None, "1.0", "tech_group",
{"description": "1.0 = normal · 1.10 = +10% (plus lent) · 0.90 = -10% (plus rapide)"}),
("Dispatch Technician", "skills", "Compétences", "Data", None, None, "tech_group",
{"description": "Séparées par des virgules, ex: fibre,cuivre,aerien"}),
("Dispatch Technician", "skill_levels", "Niveaux de compétence (JSON)", "Small Text", None, None, "skills",
{"description": "Maîtrise 1-5 par compétence (JSON, ex: {\"installation\":4}). Distinct de l'efficacité (=vitesse). Édité dans la grille Planification."}),
("Dispatch Technician", "skill_eff", "Efficacité par compétence (JSON)", "Small Text", None, None, "skill_levels",
{"description": "Facteur de vitesse PAR compétence (JSON, ex: {\"installation\":0.9}). Défaut = efficacité globale. Édité dans la grille Planification."}),
("Dispatch Technician", "cost_salary_h", "Salaire horaire ($/h)", "Float", None, "0", "efficiency", {}),
("Dispatch Technician", "cost_charges_pct", "Charges sociales (%)", "Float", None, "0", "cost_salary_h", {}),
("Dispatch Technician", "cost_other_h", "Autres coûts/h ($ véhicule, outils, frais)", "Float", None, "0", "cost_charges_pct", {}),
# ── Shift Template : quart de garde (sur appel) ──
("Shift Template", "on_call", "Garde (sur appel — non offert au booking)", "Check", None, "0", "color",
{"description": "Quart de garde/urgence : capacité de réserve. NON offert au booking client et exclu du calcul de capacité offrable. S'affiche en bande hachurée sur la timeline."}),
# ── Tech Availability : absence longue durée (à remplacer vs vacances) ──
("Tech Availability", "long_term", "Longue durée (à remplacer)", "Check", None, "0", "availability_type",
{"description": "Absence longue durée (maternité, invalidité…) : à REMPLACER lors de la réapplication d'un modèle, pas juste à sauter comme des vacances."}),
# ── Dispatch Job : prise de RDV ──
("Dispatch Job", "booking_prefs", "Préférences RDV (JSON)", "Small Text", None, None, "status", {}),
("Dispatch Job", "booking_status", "Statut RDV", "Select", "À planifier\nProposé\nConfirmé\nAnnulé\nÀ reporter", "À planifier", "booking_prefs", {}),
("Dispatch Job", "booking_token", "Jeton RDV client", "Data", None, None, "booking_status",
{"read_only": 1, "no_copy": 1}),
# ── Dispatch Job : pont de synchro avec la DB legacy (osTicket) ──
("Dispatch Job", "legacy_ticket_id", "ID ticket legacy (pont)", "Data", None, None, "ticket_id",
{"read_only": 1, "no_copy": 1, "search_index": 1,
"description": "ID du ticket osTicket legacy (table `ticket`). Renseigné par le pont legacy→dispatch (lib/legacy-dispatch-sync.js) → idempotence : 1 ticket legacy = 1 Dispatch Job."}),
("Dispatch Job", "legacy_dept", "Département legacy (pont)", "Data", None, None, "legacy_ticket_id",
{"read_only": 1, "no_copy": 1,
"description": "Département osTicket legacy (Installation Fibre / Réparation Fibre / Install-Réparation Télé / Téléphonie / Désinstallation…). Sert au coloriage des cartes dispatch « comme legacy »."}),
("Dispatch Job", "legacy_activation_url", "Lien activation TV legacy (pont)", "Small Text", None, None, "legacy_dept",
{"read_only": 1, "no_copy": 1,
"description": "Lien connect_ministra.php (activation STB/Ministra) extrait du fil du ticket legacy par le pont. Affiché tel quel dans le dispatch — MÊME lien que le tech reçoit (aucune reconstruction)."}),
("Dispatch Job", "legacy_detail", "Détails du ticket legacy (pont)", "Text", None, None, "legacy_activation_url",
{"read_only": 1, "no_copy": 1,
"description": "Description/contenu du ticket legacy (1er message du fil osTicket, HTML nettoyé) extrait par le pont → visible dans Ops sans ouvrir le legacy."}),
# ── Service Contract : installation financée (conformité CRTC 2026-43, pas de clawback) ──
("Service Contract", "monthly_regular", "Forfait — prix original (barré)", "Currency", None, None, "monthly_rate",
{"description": "Prix mensuel de référence barré (marketing). Le montant facturé reste monthly_rate. Vide/≤ = aucun barré."}),
("Service Contract", "install_fee", "Installation financée ($)", "Currency", None, None, "monthly_regular",
{"description": "Install financée sur la durée (vraie créance, pas une promo). Ex: 240 standard / 120 simple."}),
("Service Contract", "install_regular", "Installation — valeur affichée (barrée)", "Currency", None, None, "install_fee",
{"description": "Prix de référence barré (marketing), ex. 360. Le montant réellement financé/dû reste install_fee (ex. 240)."}),
]
for dt, fn, label, ft, opts, default, after, extra in FIELDS:
cf = f"{dt}-{fn}"
if frappe.db.exists("Custom Field", cf):
print("EXISTS", cf)
continue
doc = {"doctype": "Custom Field", "dt": dt, "fieldname": fn, "label": label,
"fieldtype": ft, "insert_after": after}
if opts:
doc["options"] = opts
if default is not None:
doc["default"] = default
doc.update(extra or {})
frappe.get_doc(doc).insert()
print("CREATED", cf)
frappe.db.commit()
print("DONE")

View File

@ -0,0 +1,66 @@
"""
Raccourci prix sur la fiche Item (Client Script) idempotent.
==============================================================
ERPNext stocke le prix de vente dans un doctype séparé (Item Price), pas en
champ direct peu intuitif. Ce Client Script ajoute un bouton sur la fiche Item :
Item simple / bundle « 💲 Prix de vente » : ouvre l'Item Price (Standard
Selling) existant, ou en crée un pré-rempli (item_code + price_list + selling).
Item template variantes « Variantes & prix » : liste les variantes (chacune
a son propre prix via le même bouton).
Stocké en base (doctype Client Script) survit aux recreate de conteneur
(contrairement aux patchs de fichiers). S'applique sans rebuild ni restart ;
recharger la fiche Item (hard refresh) suffit.
Exécution :
docker cp setup_item_price_button.py erpnext-backend-1:/tmp/
docker exec erpnext-backend-1 sh -lc \
"cd /home/frappe/frappe-bench && env/bin/python /tmp/setup_item_price_button.py"
"""
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path="sites")
frappe.connect()
frappe.flags.ignore_permissions = True
CS_NAME = "Item - Prix de vente (bouton)"
JS = r"""
// PRIX_VENTE_BTN raccourci vers l'Item Price (Standard Selling) depuis la fiche Item
frappe.ui.form.on('Item', {
refresh(frm) {
if (frm.is_new()) return;
if (frm.doc.has_variants) {
frm.add_custom_button('Variantes & prix', function() {
frappe.set_route('List', 'Item', { variant_of: frm.doc.name });
});
return;
}
frm.add_custom_button('💲 Prix de vente', function() {
frappe.db.get_value('Item Price',
{ item_code: frm.doc.name, price_list: 'Standard Selling', selling: 1 }, 'name'
).then(function(r) {
var name = r.message && r.message.name;
if (name) { frappe.set_route('Form', 'Item Price', name); return; }
frappe.model.with_doctype('Item Price', function() {
var d = frappe.model.get_new_doc('Item Price');
d.item_code = frm.doc.name;
d.price_list = 'Standard Selling';
d.selling = 1;
frappe.set_route('Form', 'Item Price', d.name);
});
});
}).addClass('btn-primary');
}
});
"""
if frappe.db.exists("Client Script", CS_NAME):
frappe.db.set_value("Client Script", CS_NAME, {"script": JS, "enabled": 1})
print("MAJ:", CS_NAME)
else:
frappe.get_doc({"doctype": "Client Script", "name": CS_NAME, "dt": "Item",
"view": "Form", "enabled": 1, "script": JS}).insert()
print("CRÉÉ:", CS_NAME)
frappe.db.commit()
print("DONE")

View File

@ -0,0 +1,73 @@
"""
Boutique matériel (/store) configuration ERPNext, idempotent.
==============================================================
Mécanisme de curation du Webstore : un seul champ `show_in_store` sur l'Item.
- Coché = visible dans la boutique (hub: lib/store.js GET /store/catalog).
- Rendu filtrable + visible en colonne dans la liste Item (édition en lot).
- Rapport nommé « Produits Boutique » pré-filtré pour gérer le catalogue.
Catégorie = item_group · Prix = Item Price (Standard Selling) · Stock = Bin live
· Variantes = template + Item Attribute · Bundles = Product Bundle.
Exécution :
docker cp setup_store_erpnext.py erpnext-backend-1:/tmp/
docker exec erpnext-backend-1 sh -lc \
"cd /home/frappe/frappe-bench && env/bin/python /tmp/setup_store_erpnext.py"
"""
import json as J
import frappe
frappe.init(site="erp.gigafibre.ca", sites_path="sites")
frappe.connect()
frappe.flags.ignore_permissions = True
# 1) Champ de curation + visibilité liste/filtre
CF = "Item-show_in_store"
if not frappe.db.exists("Custom Field", CF):
frappe.get_doc({
"doctype": "Custom Field", "dt": "Item", "fieldname": "show_in_store",
"label": "Afficher dans la boutique", "fieldtype": "Check",
"insert_after": "is_sales_item",
"description": "Coché = visible dans la boutique matériel (/store).",
}).insert()
print("CHAMP créé")
cf = frappe.get_doc("Custom Field", CF)
cf.in_list_view = 1 # colonne dans la liste Item
cf.in_standard_filter = 1 # filtre rapide dans la barre
cf.save()
print("CHAMP show_in_store: in_list_view + in_standard_filter OK")
# 1b) Prix barré boutique (marketing). Si > prix de vente (Item Price) → affiché barré.
CFR = "Item-store_regular_price"
if not frappe.db.exists("Custom Field", CFR):
frappe.get_doc({
"doctype": "Custom Field", "dt": "Item", "fieldname": "store_regular_price",
"label": "Prix barré (boutique)", "fieldtype": "Currency", "insert_after": "show_in_store",
"in_list_view": 1, "in_standard_filter": 1,
"description": "Prix de référence barré en boutique. Si > prix de vente (Item Price) il s'affiche barré. Vide = aucun barré.",
}).insert()
print("CHAMP store_regular_price créé")
else:
print("CHAMP store_regular_price déjà là")
# 2) Rapport nommé pré-filtré (liste gérable du catalogue)
if not frappe.db.exists("Report", "Produits Boutique"):
frappe.get_doc({
"doctype": "Report", "report_name": "Produits Boutique", "ref_doctype": "Item",
"report_type": "Report Builder", "is_standard": "No",
"json": J.dumps({
"filters": [["Item", "show_in_store", "=", 1]],
"columns": [["item_code", "Item"], ["item_name", "Item"], ["item_group", "Item"],
["standard_rate", "Item"], ["has_variants", "Item"], ["variant_of", "Item"],
["disabled", "Item"]],
"sort_by": "item_group", "sort_order": "asc", "page_length": 100,
}),
}).insert()
print("RAPPORT 'Produits Boutique' créé")
else:
print("RAPPORT 'Produits Boutique' déjà là")
frappe.db.commit()
print("show_in_store=1 :", frappe.db.count("Item", {"show_in_store": 1}))
print("DONE")

View File

@ -1,88 +1,11 @@
<template>
<div v-if="auth.loading" class="login-overlay">
<div class="login-spinner"></div>
</div>
<div v-else-if="!auth.user" class="login-overlay">
<div class="login-card">
<div class="login-header">
<svg viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" width="32" height="32"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<h1>Dispatch</h1>
</div>
<p class="login-sub">Connectez-vous avec votre compte ERPNext</p>
<div v-if="auth.error" class="login-error">{{ auth.error }}</div>
<label>Utilisateur</label>
<input v-model="usr" type="text" placeholder="Administrator" @keydown.enter="doLogin" autofocus>
<label>Mot de passe</label>
<input v-model="pwd" type="password" placeholder="Mot de passe" @keydown.enter="doLogin">
<button @click="doLogin" :disabled="!usr || !pwd">Connexion</button>
<p class="login-erp">Serveur: {{ erpUrl || 'same-origin' }}</p>
</div>
</div>
<router-view v-else />
<router-view />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { BASE_URL } from 'src/config/erpnext'
const auth = useAuthStore()
const usr = ref('')
const pwd = ref('')
const erpUrl = ref(BASE_URL)
onMounted(() => auth.checkSession())
function doLogin () {
if (usr.value && pwd.value) auth.doLogin(usr.value, pwd.value)
}
</script>
<style>
.login-overlay {
position: fixed; inset: 0; background: #0f172a;
display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
z-index: 9999;
}
.login-card {
background: #1e293b; border: 1px solid #334155; border-radius: 16px;
padding: 40px; width: 380px; color: #e2e8f0;
}
.login-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 8px;
}
.login-header h1 {
font-size: 24px; color: #6366f1; margin: 0;
}
.login-sub {
color: #94a3b8; font-size: 13px; margin-bottom: 24px;
}
.login-error {
background: rgba(239,68,68,.15); color: #f87171; padding: 8px 12px;
border-radius: 8px; font-size: 13px; margin-bottom: 16px;
}
.login-card label {
display: block; font-size: 12px; color: #94a3b8; margin-bottom: 4px; text-transform: uppercase;
}
.login-card input {
width: 100%; padding: 10px 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #e2e8f0; font-size: 14px; margin-bottom: 16px; outline: none;
}
.login-card input:focus { border-color: #6366f1; }
.login-card button {
width: 100%; padding: 12px; background: #6366f1; border: none; border-radius: 8px;
color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
}
.login-card button:hover { background: #4f46e5; }
.login-card button:disabled { opacity: .5; cursor: not-allowed; }
.login-erp {
color: #475569; font-size: 11px; text-align: center; margin-top: 16px;
}
.login-spinner {
width: 40px; height: 40px; border: 4px solid #334155;
border-top-color: #6366f1; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -1,129 +1,44 @@
// ── ERPNext auth — token-based for cross-domain, cookie fallback for same-origin
// ── ERPNext API auth — service token + Authentik session guard ──────────────
// ERPNext API calls use a service token. User auth is via Authentik forwardAuth
// at the Traefik level. If the Authentik session expires mid-use, API calls
// get redirected (302) — we detect this and reload to trigger re-auth.
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
const TOKEN_KEY = 'erp_api_token'
const USER_KEY = 'erp_user'
// Service token injected at build time via VITE_ERP_TOKEN env var
// Fallback: read from window.__ERP_TOKEN__ (set by server-side injection)
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
function getStoredToken () {
const t = localStorage.getItem(TOKEN_KEY)
if (t) return t
// Check if we have a session but no token — prompt re-login
return null
}
function getStoredUser () { return localStorage.getItem(USER_KEY) }
// Build headers with token auth if available
function authHeaders (extra = {}) {
const token = getStoredToken()
const headers = { ...extra }
if (token) headers['Authorization'] = 'token ' + token
return headers
}
// Fetch wrapper that adds auth
export function authFetch (url, opts = {}) {
const token = getStoredToken()
if (token) {
opts.headers = { ...opts.headers, Authorization: 'token ' + token }
} else {
opts.credentials = 'include'
}
return fetch(url, opts)
opts.headers = { ...opts.headers, Authorization: 'token ' + SERVICE_TOKEN }
opts.redirect = 'manual' // Don't follow redirects — detect Authentik 302
return fetch(url, opts).then(res => {
// If Traefik/Authentik redirects (session expired), reload page to re-auth
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
window.location.reload()
return new Response('{}', { status: 401 })
}
return res
})
}
export async function getCSRF () { return null }
export function getCSRF () { return null }
export function invalidateCSRF () {}
export async function login (usr, pwd) {
// 1. Try login to get session + generate API keys
const res = await fetch(BASE_URL + '/api/method/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ usr, pwd }),
})
const data = await res.json()
if (!res.ok || data.exc_type === 'AuthenticationError') {
throw new Error(data.message || 'Identifiants incorrects')
}
// 2. Generate API keys for persistent token auth (required for cross-domain PUT/DELETE)
try {
const genRes = await fetch(BASE_URL + '/api/method/frappe.core.doctype.user.user.generate_keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: usr }),
})
if (genRes.ok) {
const genData = await genRes.json()
const apiKey = genData.message?.api_key
const apiSecret = genData.message?.api_secret
if (apiKey && apiSecret) {
localStorage.setItem(TOKEN_KEY, apiKey + ':' + apiSecret)
}
}
} catch { /* API keys not available — will use session cookies */ }
localStorage.setItem(USER_KEY, usr)
return data
}
export async function login () { window.location.reload() }
export async function logout () {
try {
await authFetch(BASE_URL + '/api/method/frappe.auth.logout', { method: 'POST' })
} catch { /* ignore */ }
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
}
export async function getLoggedUser () {
// Check stored token first
const token = getStoredToken()
if (token) {
try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + token },
})
const data = await res.json()
const user = data.message
if (user && user !== 'Guest') return user
} catch { /* token invalid */ }
// Token failed — clear it
localStorage.removeItem(TOKEN_KEY)
}
// Fallback to cookie (Authentik SSO, same-origin session)
try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
credentials: 'include',
headers: { Authorization: 'token ' + SERVICE_TOKEN },
})
const data = await res.json()
const user = data.message
if (!user || user === 'Guest') return null
// User authenticated via cookie but no API token — generate one
// (required for cross-domain PUT/DELETE from dispatch.gigafibre.ca → erp.gigafibre.ca)
try {
const genRes = await fetch(BASE_URL + '/api/method/frappe.core.doctype.user.user.generate_keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user }),
})
if (genRes.ok) {
const genData = await genRes.json()
const apiKey = genData.message?.api_key
const apiSecret = genData.message?.api_secret
if (apiKey && apiSecret) {
localStorage.setItem(TOKEN_KEY, apiKey + ':' + apiSecret)
console.log('[Auth] API token generated for', user)
}
}
} catch { /* token generation failed — will use cookies */ }
return user
} catch {
return null
}
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
return 'authenticated'
}

View File

@ -1,42 +1,51 @@
// ── Auth store ───────────────────────────────────────────────────────────────
// Holds current session state. Calls api/auth.js only.
// To change the auth method: edit api/auth.js. This store stays the same.
// ── Auth store — Authentik forwardAuth ──────────────────────────────────────
// Authentik handles login at the Traefik level. If the user reaches the app,
// they are already authenticated. We fetch their identity from the /api/ proxy
// which forwards Authentik headers to ERPNext.
// ERPNext API calls use a service token (not user session).
// ─────────────────────────────────────────────────────────────────────────────
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login, logout, getLoggedUser } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
// Service token for ERPNext API — all dispatch API calls use this
const ERP_SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
export const useAuthStore = defineStore('auth', () => {
const user = ref(null) // email string when logged in, null when guest
const loading = ref(true) // true until first checkSession() completes
const user = ref(null)
const loading = ref(true)
const error = ref('')
async function checkSession () {
loading.value = true
try {
user.value = await getLoggedUser()
} finally {
loading.value = false
}
}
async function doLogin (usr, pwd) {
loading.value = true
error.value = ''
try {
await login(usr, pwd)
user.value = usr
} catch (e) {
error.value = e.message || 'Erreur de connexion'
// Fetch user identity — the /api/ proxy passes Authentik headers to ERPNext
// We use the service token to query who we are
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + ERP_SERVICE_TOKEN },
})
if (res.ok) {
const data = await res.json()
// For now, use the service account identity
// The actual Authentik user email is in the response headers (X-authentik-email)
// but those are only available at the Traefik level
user.value = data.message || 'authenticated'
} else {
user.value = 'authenticated' // Authentik guarantees auth, ERPNext may not know the user
}
} catch {
user.value = 'authenticated' // If ERPNext is down, user is still authenticated via Authentik
} finally {
loading.value = false
}
}
async function doLogout () {
await logout()
user.value = null
// Redirect to Authentik logout
window.location.href = 'https://auth.targo.ca/application/o/gigafibre-dispatch/end-session/'
}
return { user, loading, error, checkSession, doLogin, doLogout }
return { user, loading, error, checkSession, doLogin: checkSession, doLogout }
})
export function getServiceToken () { return ERP_SERVICE_TOKEN }