Compare commits
20 Commits
8a42660284
...
f6576e38af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6576e38af | ||
|
|
afbc43cc07 | ||
|
|
0f1f21a966 | ||
|
|
05dfe5aa17 | ||
|
|
c6e2856a8b | ||
|
|
a4f82359c7 | ||
|
|
fa1172a495 | ||
|
|
49721eae8e | ||
|
|
aad579d8bb | ||
|
|
536bd2dfa8 | ||
|
|
4760ae5e73 | ||
|
|
e47ecd26ac | ||
|
|
f61f99df18 | ||
|
|
7b9f9c935d | ||
|
|
c397c457a5 | ||
|
|
7ea546bbbd | ||
|
|
6fc8a2d37f | ||
|
|
1263786b90 | ||
|
|
7ef22873f0 | ||
|
|
f1faffeab9 |
105
frappe-setup/apply_pg_patches.py
Normal file
105
frappe-setup/apply_pg_patches.py
Normal 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.")
|
||||
130
frappe-setup/create_roster_doctypes.py
Normal file
130
frappe-setup/create_roster_doctypes.py
Normal 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 tech↔date↔shift (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")
|
||||
83
frappe-setup/setup_dispatch_custom_fields.py
Normal file
83
frappe-setup/setup_dispatch_custom_fields.py
Normal 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")
|
||||
66
frappe-setup/setup_item_price_button.py
Normal file
66
frappe-setup/setup_item_price_button.py
Normal 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")
|
||||
73
frappe-setup/setup_store_erpnext.py
Normal file
73
frappe-setup/setup_store_erpnext.py
Normal 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")
|
||||
81
src/App.vue
81
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
141
src/api/auth.js
141
src/api/auth.js
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user