diff --git a/app.py b/app.py
index fec2022..448164d 100644
--- a/app.py
+++ b/app.py
@@ -1,3 +1,24 @@
+"""
+3D Printing Pricing & Planning App
+====================================
+Flask/SQLite application for managing 3D print job costing, scheduling,
+and Home Assistant sync for a small print shop.
+
+Architecture:
+ - Flask routes (server-rendered Jinja2 templates)
+ - SQLite database with WAL mode (concurrent read/write safe)
+ - Background thread for Home Assistant polling
+ - vis.js Timeline Gantt on the planning page
+ - Optional OIDC authentication via Authelia
+
+Key modules:
+ calc() — core pricing engine (per piece, with fiscal charges)
+ parse_3mf() — Bambu Studio .3mf file parser
+ find_next_slot() — automatic next available printer slot finder
+ _ha_sync_once() — Home Assistant state → slot status synchronization
+ planning() — Gantt + unscheduled jobs view
+"""
+
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response, session, flash
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools, threading, time
try:
@@ -75,6 +96,12 @@ def require_login_global():
return redirect(url_for('login'))
def get_db():
+ """
+ Ouvre une connexion SQLite avec WAL mode activé.
+ WAL (Write-Ahead Log) permet lectures et écritures simultanées
+ sans blocage — critique pour le thread HA en arrière-plan.
+ Timeout 30s pour attendre si une autre écriture est en cours.
+ """
conn = sqlite3.connect(DATABASE, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode=WAL')
@@ -274,6 +301,7 @@ def init_db():
'ALTER TABLE print_slots ADD COLUMN pieces_ignored INTEGER DEFAULT 0',
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
'ALTER TABLE calendar_blocks ADD COLUMN date_end TEXT DEFAULT NULL',
+ 'ALTER TABLE jobs ADD COLUMN source_nc_path TEXT DEFAULT ""',
]
for sql in migrations:
try:
@@ -429,6 +457,38 @@ def parse_3mf(file_stream):
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
price_per_kg_override=None, s=None, pieces_per_plate=1):
+ """
+ Moteur de pricing principal — calcule le prix de vente d'une pièce.
+
+ Flux de calcul (toutes les valeurs sont PAR PIÈCE) :
+ 1. Coût matière = prix_kg/1000 × poids_plateau / pièces_plateau
+ 2. Marge matière = coût_matière × material_margin_pct
+ 3. Design = coût_matière × design_multiplier
+ 4. Manutention = taux_horaire × minutes_plateau / 60 / pièces
+ 5. Usure machine = (prix_machine / durée_vie) × temps_impression / pièces
+ 6. Électricité = puissance_kw × heures × prix_kwh / pièces
+ 7. Coût fixe = matière + marge_matière + usure + élec
+ 8. Sous-total = coût_fixe + manutention + design
+ 9. Marge brute = sous-total × gross_margin_pct / 100
+ 10. Total marge = manutention + design + marge_brute
+ 11. Prix HT = coût_fixe + total_marge
+ 12. Prix HT net = prix_ht / (1 - charges_fiscales)
+ (cotisations + VFL + autres taxes sont self-contained dans le prix)
+ 13. Prix TTC = prix_ht_net × (1 + tva)
+ 14. Prix final = prix_ttc × (1 - remise)
+
+ Args:
+ weight_g: poids total du plateau Bambu (en g)
+ print_time_s: durée d'impression plateau en secondes
+ design_mult: multiplicateur design (ex: 0.8 ou 1.8)
+ gross_margin_pct: marge brute en % (ex: 27.5)
+ discount_pct: remise en % (ex: 10.0)
+ price_per_kg_override: prix filament override (sinon settings)
+ s: dict settings (sinon get_settings() appelé)
+ pieces_per_plate: nb pièces par plateau (divise les coûts fixes)
+
+ Returns: dict avec tous les postes de coût et prix final.
+ """
if s is None:
s = get_settings()
if pieces_per_plate < 1:
@@ -602,7 +662,13 @@ def calc_capacity(print_time_s, pieces_per_plate, days_ahead=30):
}
def find_next_slot(print_time_s):
- """Trouve le prochain créneau libre sur une imprimante disponible."""
+ """
+ Trouve le prochain créneau libre sur une imprimante disponible.
+ Parcourt jusqu'à 60 jours, en tenant compte du planning horaire
+ (TT vs onsite) et des indisponibilités (calendar_blocks).
+ Retourne un dict {printer_id, printer_name, planned_start, planned_end}
+ ou None si aucun créneau trouvé dans les 60 jours.
+ """
s = get_settings()
cooldown_min = int(s.get('cooldown_minutes', 15))
max_printers = int(s.get('max_simultaneous_printers', 5))
@@ -1194,6 +1260,7 @@ def new_job():
prefill = dict(prefill)
prefill['hours'] = prefill['print_time_s'] // 3600
prefill['minutes'] = (prefill['print_time_s'] % 3600) // 60
+ # source_nc_path transmis au template pour auto-parse Nextcloud
if request.method == 'POST':
weight_g = float(request.form['weight_g'])
@@ -1214,6 +1281,7 @@ def new_job():
pieces_per_plate = max(1, int(request.form.get('pieces_per_plate', 1)))
order_qty = max(1, int(request.form.get('order_qty', 1)))
plate_name = request.form.get('plate_name', '').strip()
+ source_nc_path = request.form.get('source_nc_path', '').strip()
s = dict(settings)
if machine_pid:
@@ -1246,16 +1314,16 @@ def new_job():
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount,
price_kg, s, pieces_per_plate)
conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id,
- material_profile_id,pricing_profile_id,project_id,source_file,plate_name,name,description,
+ material_profile_id,pricing_profile_id,project_id,source_file,source_nc_path,plate_name,name,description,
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
pieces_per_plate,order_qty,
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht,
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
price_per_piece,final_price,notes)
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id,
- source_file,plate_name,request.form['name'],request.form.get('description',''),
+ source_file,source_nc_path,plate_name,request.form['name'],request.form.get('description',''),
weight_g,print_time_s,design_mult,gross_margin,discount,
pieces_per_plate,order_qty,
r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'],
@@ -1725,10 +1793,19 @@ def api_slot_pieces_override(id):
def _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=None):
- """Vérifie si ajouter ce créneau dépasserait max_printers simultanés.
- Retourne (True, peak_count) si limite atteinte, (False, peak_count) sinon.
- Méthode : on évalue le nombre de slots actifs à chaque 'event point'
- (toutes les heures de début des créneaux qui chevauchent la fenêtre).
+ """
+ Vérifie que le nombre max d'imprimantes simultanées ne sera pas dépassé.
+
+ Méthode event-point :
+ 1. Récupère tous les slots actifs qui chevauchent la fenêtre demandée
+ 2. Si < max_printers, retourne False immédiatement
+ 3. Sinon, teste le nb de slots actifs à chaque heure de début
+ (points d'événement) et retourne True si le pic atteint max_printers.
+
+ Note: n'inclut que les slots dont planned_end > now (évite de bloquer
+ sur des impressions physiquement terminées mais non clôturées).
+
+ Returns: (bool over_capacity, int peak_count)
"""
q = """SELECT planned_start, planned_end FROM print_slots
WHERE status NOT IN ('cancelled','failed','done')
@@ -2334,6 +2411,31 @@ def client_portal(token):
conn.close()
return render_template('client_portal.html', job=job, slots=slots)
+@app.route('/api/nextcloud/parse')
+def nc_parse():
+ """Fetch un fichier .3mf depuis Nextcloud et retourne les données parsées (JSON).
+ Utilisé lors de la duplication d'un job pour recharger les plateaux sans re-sélectionner le fichier."""
+ path = request.args.get('path', '')
+ if not path:
+ return jsonify({'error': 'Paramètre path manquant'}), 400
+ sess, base_url, username = _nc_session()
+ if not sess:
+ return jsonify({'error': 'Nextcloud non configuré'}), 503
+ dav_url = _dav_url(base_url, username, path)
+ try:
+ r = sess.get(dav_url, timeout=30)
+ if r.status_code != 200:
+ return jsonify({'error': f'Erreur WebDAV {r.status_code}'}), 502
+ import io as _io
+ result = parse_3mf(_io.BytesIO(r.content))
+ filename = path.split('/')[-1]
+ clean = re.sub(r'\.gcode\.3mf$', '', filename, flags=re.IGNORECASE)
+ clean = re.sub(r'\.3mf$', '', clean, flags=re.IGNORECASE)
+ result['filename'] = clean
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
@app.route('/api/nextcloud/file')
def nc_file():
"""Proxy WebDAV : télécharge un fichier Nextcloud et le sert au navigateur."""
diff --git a/templates/new_job.html b/templates/new_job.html
index 96b17de..fece566 100644
--- a/templates/new_job.html
+++ b/templates/new_job.html
@@ -67,6 +67,8 @@
+