""" 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: import requests as _requests except ImportError: _requests = None from datetime import datetime, timedelta, date as date_cls, timezone from authlib.integrations.flask_client import OAuth app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32)) DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db') # ─── OIDC ───────────────────────────────────────────────────────────────────── OIDC_ENABLED = bool(os.environ.get('OIDC_CLIENT_ID')) oauth = OAuth(app) if OIDC_ENABLED: oauth.register( name='authelia', client_id=os.environ.get('OIDC_CLIENT_ID'), client_secret=os.environ.get('OIDC_CLIENT_SECRET'), server_metadata_url=os.environ.get('OIDC_DISCOVERY_URL'), client_kwargs={ 'scope': 'openid profile email', 'code_challenge_method': 'S256', # PKCE }, ) def login_required(f): @functools.wraps(f) def decorated(*args, **kwargs): if OIDC_ENABLED and 'user' not in session: session['next'] = request.url return redirect(url_for('login')) return f(*args, **kwargs) return decorated @app.route('/login') def login(): if not OIDC_ENABLED: return redirect(url_for('dashboard')) redirect_uri = url_for('auth_callback', _external=True) return oauth.authelia.authorize_redirect(redirect_uri) @app.route('/auth/callback') def auth_callback(): token = oauth.authelia.authorize_access_token() userinfo = token.get('userinfo') or oauth.authelia.userinfo() session['user'] = { 'sub': userinfo.get('sub', ''), 'name': userinfo.get('name') or userinfo.get('preferred_username', 'Utilisateur'), 'email': userinfo.get('email', ''), } next_url = session.pop('next', None) return redirect(next_url or url_for('dashboard')) @app.route('/logout') def logout(): session.clear() if OIDC_ENABLED: end_session = os.environ.get('OIDC_END_SESSION_URL', '') if end_session: post_logout = url_for('login', _external=True) return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}") return redirect(url_for('login')) # Routes accessibles sans login _OPEN_ROUTES = {'login', 'auth_callback', 'logout', 'static', 'client_portal', 'calendar_ics'} @app.before_request def require_login_global(): if OIDC_ENABLED and request.endpoint not in _OPEN_ROUTES and 'user' not in session: session['next'] = request.url 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') conn.execute('PRAGMA synchronous=NORMAL') return conn def init_db(): os.makedirs(os.path.dirname(DATABASE), exist_ok=True) conn = get_db() conn.executescript(''' CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, deadline TEXT DEFAULT NULL, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT DEFAULT '', design_multiplier REAL DEFAULT 1.80, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS materials ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, brand TEXT DEFAULT '', type TEXT DEFAULT 'PLA', color TEXT DEFAULT '', price_per_kg REAL NOT NULL DEFAULT 25.0, stock_g REAL DEFAULT 0, low_stock_threshold_g REAL DEFAULT 200, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS material_price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, price_per_kg REAL NOT NULL, recorded_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS machine_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, printer_price REAL DEFAULT 400.0, printer_lifespan_hours REAL DEFAULT 5000, nozzle_price REAL DEFAULT 10.0, nozzle_lifespan_hours REAL DEFAULT 250, plate_price REAL DEFAULT 25.0, plate_lifespan_hours REAL DEFAULT 1000, printer_power_kw REAL DEFAULT 0.35, electricity_price_kwh REAL DEFAULT 0.18, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS handling_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, handling_rate_per_hour REAL DEFAULT 40.0, handling_minutes_per_plate REAL DEFAULT 5, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS material_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, material_margin_pct REAL DEFAULT 20.0, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS pricing_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, design_multiplier REAL DEFAULT 1.80, gross_margin_pct REAL DEFAULT 27.5, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, material_id INTEGER REFERENCES materials(id) ON DELETE SET NULL, machine_profile_id INTEGER REFERENCES machine_profiles(id) ON DELETE SET NULL, handling_profile_id INTEGER REFERENCES handling_profiles(id) ON DELETE SET NULL, material_profile_id INTEGER REFERENCES material_profiles(id) ON DELETE SET NULL, pricing_profile_id INTEGER REFERENCES pricing_profiles(id) ON DELETE SET NULL, source_file TEXT DEFAULT '', plate_name TEXT DEFAULT '', name TEXT NOT NULL, description TEXT DEFAULT '', weight_g REAL NOT NULL, print_time_s INTEGER NOT NULL, design_multiplier REAL NOT NULL, gross_margin_pct REAL NOT NULL DEFAULT 27.5, discount_pct REAL DEFAULT 0, pieces_per_plate INTEGER DEFAULT 1, order_qty INTEGER DEFAULT 1, material_cost REAL, material_margin REAL, design_cost REAL, handling_cost REAL, wear_cost REAL, electricity_cost REAL, cout_fixe REAL, total_marge REAL, subtotal REAL, margin_amount REAL, cotisations_amount REAL, vfl_amount REAL, tva_amount REAL, other_taxes_amount REAL, tax_amount REAL, price_per_piece REAL, final_price REAL, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS printers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, is_active INTEGER DEFAULT 1, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS working_schedule ( day_of_week INTEGER PRIMARY KEY, mode TEXT DEFAULT 'onsite', tt_window_start TEXT DEFAULT '10:00', tt_window_end TEXT DEFAULT '22:00', onsite_slot_1 TEXT DEFAULT '07:00', onsite_slot_2 TEXT DEFAULT '18:30' ); CREATE TABLE IF NOT EXISTS calendar_blocks ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, type TEXT DEFAULT 'off', printer_id INTEGER, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS print_slots ( id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER REFERENCES jobs(id) ON DELETE CASCADE, printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, planned_start TEXT NOT NULL, planned_end TEXT NOT NULL, status TEXT DEFAULT 'planned', failure_note TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); ''') defaults = [ ('filament_price_per_kg','25.0'),('electricity_price_kwh','0.18'), ('printer_price','400.0'),('printer_lifespan_hours','5000'), ('nozzle_price','10.0'),('nozzle_lifespan_hours','250'), ('plate_price','25.0'),('plate_lifespan_hours','1000'), ('handling_rate_per_hour','40.0'),('handling_minutes_per_plate','5'), ('material_margin_pct','20.0'), ('cotisations_rate_pct','12.3'), ('vfl_rate_pct','1.0'), ('tva_rate_pct','20.0'), ('other_taxes_rate_pct','1.0'), ('printer_power_kw','0.35'), ('cooldown_minutes','15'), ('failure_buffer_pct','10'), ('max_simultaneous_printers','5'), ('nc_url',''), ('nc_username',''), ('nc_app_password',''), ('nc_root_path','/3D'), ('ical_token',''), ('ha_url',''), ('ha_token',''), ('ha_poll_interval','60'), ] conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults) # Default printers: 6 A1, #1-#5 active, #6 inactive for i in range(1, 7): conn.execute('INSERT OR IGNORE INTO printers(id,name,is_active) VALUES(?,?,?)', (i, f'A1 #{i}', 1 if i <= 5 else 0)) # Default working schedule: Mon/Wed/Fri = TT, others = onsite tt_days = {0, 2, 4} for dow in range(7): mode = 'tt' if dow in tt_days else 'onsite' conn.execute('''INSERT OR IGNORE INTO working_schedule (day_of_week,mode,tt_window_start,tt_window_end,onsite_slot_1,onsite_slot_2) VALUES(?,?,?,?,?,?)''', (dow, mode, '10:00', '22:00', '07:00', '18:30')) # Migrations migrations = [ 'ALTER TABLE jobs ADD COLUMN machine_profile_id INTEGER', 'ALTER TABLE jobs ADD COLUMN handling_profile_id INTEGER', 'ALTER TABLE jobs ADD COLUMN material_profile_id INTEGER', 'ALTER TABLE jobs ADD COLUMN pricing_profile_id INTEGER', 'ALTER TABLE jobs ADD COLUMN cout_fixe REAL', 'ALTER TABLE jobs ADD COLUMN total_marge REAL', 'ALTER TABLE jobs ADD COLUMN cotisations_amount REAL', 'ALTER TABLE jobs ADD COLUMN vfl_amount REAL', 'ALTER TABLE jobs ADD COLUMN tva_amount REAL', 'ALTER TABLE jobs ADD COLUMN other_taxes_amount REAL', 'ALTER TABLE jobs ADD COLUMN pieces_per_plate INTEGER DEFAULT 1', 'ALTER TABLE jobs ADD COLUMN order_qty INTEGER DEFAULT 1', 'ALTER TABLE jobs ADD COLUMN price_per_piece REAL', 'ALTER TABLE clients ADD COLUMN default_machine_profile_id INTEGER', 'ALTER TABLE clients ADD COLUMN default_handling_profile_id INTEGER', 'ALTER TABLE clients ADD COLUMN default_material_profile_id INTEGER', 'ALTER TABLE clients ADD COLUMN default_pricing_profile_id INTEGER', 'ALTER TABLE jobs ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL', 'ALTER TABLE print_slots ADD COLUMN wasted_g REAL DEFAULT 0', 'ALTER TABLE materials ADD COLUMN total_wasted_g REAL DEFAULT 0', 'ALTER TABLE jobs ADD COLUMN client_token TEXT DEFAULT NULL', 'ALTER TABLE jobs ADD COLUMN plate_name TEXT DEFAULT ""', 'ALTER TABLE jobs ADD COLUMN marge_pct_on_ht REAL DEFAULT 0', 'ALTER TABLE printers ADD COLUMN ha_entity_prefix TEXT DEFAULT ""', '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: conn.execute(sql) except Exception: pass conn.commit(); conn.close() def get_settings(): conn = get_db() rows = conn.execute('SELECT key,value FROM settings').fetchall() conn.close() result = {} for r in rows: try: result[r['key']] = float(r['value']) except (ValueError, TypeError): result[r['key']] = r['value'] or '' return result def parse_3mf(file_stream): """ Parse un fichier .3mf Bambu Studio. Retourne un dict avec : - plates : liste de plaques [{index, weight_g, print_time_s, filaments:[{type,color,used_g}]}] - fallback : {weight_g, hours, minutes} si slice_info absent - warning : message si le fichier n'a pas été slicé """ result = {'plates': [], 'weight_g': None, 'hours': None, 'minutes': None, 'warning': None} try: with zipfile.ZipFile(file_stream) as z: names = z.namelist() # ── 0. Lire les noms custom depuis model_settings.config ──────── # Bambu stocke le nom de plateau dans plater_name (pas dans slice_info) plater_names = {} # {plate_index: name} model_settings_candidates = [n for n in names if 'model_settings' in n.lower() and n.endswith('.config')] if model_settings_candidates: try: ms_raw = z.read(model_settings_candidates[0]).decode('utf-8', errors='ignore') ms_root = ET.fromstring(ms_raw) for plate_el in ms_root.findall('plate'): ms_meta = {m.get('key'): m.get('value') for m in plate_el.findall('metadata')} pid = int(ms_meta.get('plater_id', 0)) pname = ms_meta.get('plater_name', '') or '' if pid and pname: plater_names[pid] = pname except Exception: pass # ── 1. Bambu slice_info.config (format XML par plaque) ────────── slice_info_candidates = [n for n in names if 'slice_info' in n.lower() and n.endswith('.config')] if slice_info_candidates: raw = z.read(slice_info_candidates[0]).decode('utf-8', errors='ignore') try: root = ET.fromstring(raw) for plate_el in root.findall('plate'): plate = {} meta = {m.get('key'): m.get('value') for m in plate_el.findall('metadata')} plate['index'] = int(meta.get('index', 0)) plate['print_time_s'] = int(meta.get('prediction', 0)) # Nom custom : priorité à model_settings, fallback sur slice_info plate['name'] = plater_names.get(plate['index'], '') or meta.get('name', '') or '' filaments = [] total_g = 0.0 for fil in plate_el.findall('filament'): used_g = float(fil.get('used_g', 0) or 0) total_g += used_g filaments.append({ 'id': fil.get('id', ''), 'type': fil.get('type', ''), 'color': fil.get('color', ''), 'used_g': round(used_g, 2), 'used_m': round(float(fil.get('used_m', 0) or 0), 2), }) plate['weight_g'] = round(total_g, 2) plate['filaments'] = filaments # Pièces : compter les non skippés objs = [o for o in plate_el.findall('object') if o.get('skipped') != 'true'] plate['pieces'] = len(objs) # Noms uniques des modèles (sans extension) plate['object_names'] = list(dict.fromkeys( o.get('name', '').rsplit('.', 1)[0] for o in objs if o.get('name') )) # Libellé lisible h = plate['print_time_s'] // 3600 mn = (plate['print_time_s'] % 3600) // 60 name_part = f" «{plate['name']}»" if plate['name'] else '' plate['label'] = ( f"Plateau {plate['index']}{name_part}" f" — {plate['weight_g']} g" f" — {h}h{mn:02d}m" f" — {plate['pieces']} pce(s)" ) result['plates'].append(plate) result['plates'].sort(key=lambda p: p['index']) except Exception: pass # ── 2. Fallback regex si slice_info absent ────────────────────── if not result['plates']: for name in names: if not any(name.endswith(e) for e in ['.xml', '.model', '.config', '.json', '.txt']): continue try: raw = z.read(name).decode('utf-8', errors='ignore') except Exception: continue if result['weight_g'] is None: for pat in [ r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)', r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)', r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)', r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])', ]: m = re.search(pat, raw, re.IGNORECASE) if m: try: result['weight_g'] = float(m.group(1).replace(',', '.')) break except ValueError: pass if result['hours'] is None: for pat in [ r'(\d+)h\s*(\d+)m', r'(\d+)\s*hours?\s*(\d+)\s*min', r'printing.time[^"]*"\s*value\s*=\s*"(\d+)h\s*(\d+)', ]: m = re.search(pat, raw, re.IGNORECASE) if m: try: h, mn = int(m.group(1)), int(m.group(2)) if 0 <= h <= 500 and 0 <= mn <= 59: result['hours'] = h result['minutes'] = mn break except (ValueError, IndexError): pass except Exception: pass # Si aucune donnée trouvée, ajouter un avertissement if not result['plates'] and result['weight_g'] is None: result['warning'] = ( "Aucune donnée de découpe dans ce fichier. " "Dans Bambu Studio : slicez vos plateaux puis utilisez " "Fichier → Exporter → Exporter le fichier découpé (pas Sauvegarder le projet). " "Le fichier exporté contiendra poids, temps et consommation filament." ) return result 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: pieces_per_plate = 1 # weight_g et print_time_s = valeurs PLATEAU complet (Bambu Studio) # Tous les coûts sont ramenés à la pièce en divisant par pieces_per_plate price_kg = price_per_kg_override if price_per_kg_override is not None else s['filament_price_per_kg'] weight_per_piece = weight_g / pieces_per_plate mat = (price_kg / 1000) * weight_per_piece mat_margin = mat * (s['material_margin_pct'] / 100) design = mat * design_mult # Manutention, usure et élec divisées par pièces/plateau handling = (s['handling_rate_per_hour'] / 60) * s['handling_minutes_per_plate'] / pieces_per_plate printer_w = (s['printer_price'] / (s['printer_lifespan_hours'] * 3600)) * print_time_s / pieces_per_plate nozzle_w = (s['nozzle_price'] / (s['nozzle_lifespan_hours'] * 3600)) * print_time_s / pieces_per_plate plate_w = (s['plate_price'] / (s['plate_lifespan_hours'] * 3600)) * print_time_s / pieces_per_plate wear = printer_w + nozzle_w + plate_w elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh'] / pieces_per_plate cout_fixe = mat + mat_margin + wear + elec sous_total = cout_fixe + handling + design margin = sous_total * (gross_margin_pct / 100) total_marge= handling + design + margin prix_ht = cout_fixe + total_marge cot_rate = s.get('cotisations_rate_pct', 12.3) / 100 vfl_rate = s.get('vfl_rate_pct', 0.0) / 100 tva_rate = s.get('tva_rate_pct', 0.0) / 100 other_taxes_rate = s.get('other_taxes_rate_pct', 1.0) / 100 total_charges = cot_rate + vfl_rate + other_taxes_rate if total_charges < 1.0: prix_ht_net = prix_ht / (1 - total_charges) else: prix_ht_net = prix_ht cot_amount = prix_ht_net * cot_rate vfl_amount = prix_ht_net * vfl_rate other_taxes_amount = prix_ht_net * other_taxes_rate tva_amount = prix_ht_net * tva_rate prix_ttc = prix_ht_net + tva_amount final = prix_ttc * (1 - discount_pct / 100) marge_pct_on_ht = (total_marge / prix_ht_net * 100) if prix_ht_net > 0 else 0 return { 'material_cost': round(mat, 4), 'material_margin': round(mat_margin, 4), 'wear_cost': round(wear, 4), 'printer_wear': round(printer_w, 4), 'nozzle_wear': round(nozzle_w, 4), 'plate_wear': round(plate_w, 4), 'electricity_cost': round(elec, 4), 'kwh_used': round(s['printer_power_kw'] * (print_time_s / 3600), 4), 'cout_fixe': round(cout_fixe, 4), 'handling_cost': round(handling, 4), 'design_cost': round(design, 4), 'subtotal': round(sous_total, 4), 'margin_amount': round(margin, 4), 'total_marge': round(total_marge, 4), 'marge_pct_on_ht': round(marge_pct_on_ht, 1), 'prix_ht': round(prix_ht, 4), 'cotisations_amount': round(cot_amount, 4), 'vfl_amount': round(vfl_amount, 4), 'other_taxes_amount': round(other_taxes_amount, 4), 'tva_amount': round(tva_amount, 4), 'prix_ht_net': round(prix_ht_net, 4), 'prix_ttc': round(prix_ttc, 4), 'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4), 'final_price': round(final, 2), '_cot_pct': round(cot_rate * 100, 2), '_vfl_pct': round(vfl_rate * 100, 2), '_tva_pct': round(tva_rate * 100, 2), '_other_taxes_pct': round(other_taxes_rate * 100, 2), '_material_margin_pct': s['material_margin_pct'], '_handling_rate': s['handling_rate_per_hour'], '_handling_minutes': s['handling_minutes_per_plate'], '_elec_price': s['electricity_price_kwh'], '_pieces_per_plate': pieces_per_plate, } # ── Planning helpers ────────────────────────────────────────────────────────── def get_schedule_dict(): conn = get_db() rows = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() conn.close() return {r['day_of_week']: dict(r) for r in rows} def get_blocks_dict(from_date=None, to_date=None): conn = get_db() if from_date and to_date: # A block overlaps [from_date, to_date] when: # block.date <= to_date AND COALESCE(block.date_end, block.date) >= from_date rows = conn.execute( """SELECT * FROM calendar_blocks WHERE date <= ? AND COALESCE(date_end, date) >= ? ORDER BY date""", (str(to_date), str(from_date))).fetchall() else: rows = conn.execute('SELECT * FROM calendar_blocks ORDER BY date').fetchall() conn.close() return [dict(r) for r in rows] def get_day_launches(check_date, schedule, blocks, cycle_min): """Returns number of possible launches on a given date (per machine).""" date_str = str(check_date) dow = check_date.weekday() block = next((b for b in blocks if b['date'] <= date_str and (b['date_end'] or b['date']) >= date_str and b['printer_id'] is None), None) mode = block['type'] if block else schedule[dow]['mode'] if mode == 'off': return 0, mode elif mode == 'tt': sched = schedule[dow] h0, m0 = [int(x) for x in sched['tt_window_start'].split(':')] h1, m1 = [int(x) for x in sched['tt_window_end'].split(':')] window_min = (h1 * 60 + m1) - (h0 * 60 + m0) launches = max(1, int(window_min / cycle_min)) if cycle_min > 0 else 1 return launches, mode elif mode == 'onsite': return 2, mode return 0, mode def calc_capacity(print_time_s, pieces_per_plate, days_ahead=30): """Calcule la capacité de production sur N jours à venir.""" s = get_settings() cooldown_min = s.get('cooldown_minutes', 15) max_printers = int(s.get('max_simultaneous_printers', 5)) failure_buf = s.get('failure_buffer_pct', 10) / 100 cycle_min = print_time_s / 60 + cooldown_min if cycle_min <= 0: cycle_min = 1 conn = get_db() active_count = conn.execute('SELECT COUNT(*) FROM printers WHERE is_active=1').fetchone()[0] conn.close() effective = min(active_count, max_printers) schedule = get_schedule_dict() today = date_cls.today() blocks = get_blocks_dict(today, today + timedelta(days=days_ahead)) pieces_per_day_first = None week_total = 0 month_total = 0 for offset in range(days_ahead): d = today + timedelta(days=offset) launches, _ = get_day_launches(d, schedule, blocks, cycle_min) pieces = launches * pieces_per_plate * effective if pieces_per_day_first is None and pieces > 0: pieces_per_day_first = pieces if offset < 7: week_total += pieces month_total += pieces # Nb plateaux pour 1 commande (calculé côté appelant) return { 'pieces_per_day': pieces_per_day_first or 0, 'pieces_per_week': week_total, 'pieces_per_month': month_total, 'effective_printers': effective, 'cycle_minutes': round(cycle_min, 1), 'failure_buffer_pct': round(failure_buf * 100, 1), } def find_next_slot(print_time_s): """ 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)) total_sec = print_time_s + cooldown_min * 60 conn = get_db() printers = conn.execute( 'SELECT * FROM printers WHERE is_active=1 ORDER BY id LIMIT ?', (max_printers,) ).fetchall() existing = conn.execute( "SELECT printer_id, planned_start, planned_end FROM print_slots WHERE status NOT IN ('cancelled','failed')" ).fetchall() conn.close() schedule = get_schedule_dict() today = date_cls.today() blocks = get_blocks_dict(today, today + timedelta(days=60)) # Build busy intervals per printer busy = {} for sl in existing: pid = sl['printer_id'] if pid not in busy: busy[pid] = [] busy[pid].append((sl['planned_start'], sl['planned_end'])) for offset in range(60): d = today + timedelta(days=offset) launches, mode = get_day_launches(d, schedule, blocks, (print_time_s / 60) + cooldown_min) if launches == 0: continue date_str = str(d) sched = schedule[d.weekday()] # Build candidate launch times candidates = [] if mode == 'tt': h0, m0 = [int(x) for x in sched['tt_window_start'].split(':')] candidates.append(datetime(d.year, d.month, d.day, h0, m0)) # Add slots every cycle within window h1, m1 = [int(x) for x in sched['tt_window_end'].split(':')] window_end = datetime(d.year, d.month, d.day, h1, m1) t = candidates[0] while True: t = t + timedelta(seconds=total_sec) if t >= window_end: break candidates.append(t) else: h0, m0 = [int(x) for x in sched['onsite_slot_1'].split(':')] h1, m1 = [int(x) for x in sched['onsite_slot_2'].split(':')] candidates.append(datetime(d.year, d.month, d.day, h0, m0)) candidates.append(datetime(d.year, d.month, d.day, h1, m1)) now = datetime.now() for start_dt in candidates: if start_dt <= now: # ignorer les créneaux déjà passés continue end_dt = start_dt + timedelta(seconds=total_sec) for printer in printers: pid = printer['id'] printer_busy = busy.get(pid, []) conflict = any( not (end_dt.isoformat() <= bs or start_dt.isoformat() >= be) for bs, be in printer_busy ) if not conflict: return { 'printer_id': pid, 'printer_name': printer['name'], 'planned_start': start_dt.strftime('%Y-%m-%dT%H:%M'), 'planned_end': end_dt.strftime('%Y-%m-%dT%H:%M'), } return None def fmt_time(s): return f"{s//3600}h{(s%3600)//60:02d}m" app.jinja_env.filters['fmt_time'] = fmt_time _FRENCH_COLORS = { 'blanc': '#ffffff', 'noir': '#111111', 'gris': '#6b7280', 'argent': '#9ca3af', 'rouge': '#ef4444', 'rose': '#ec4899', 'fuchsia': '#d946ef', 'orange': '#f97316', 'jaune': '#eab308', 'or': '#d97706', 'vert': '#22c55e', 'olive': '#65a30d', 'emeraude': '#10b981', 'bleu': '#3b82f6', 'marine': '#1e3a8a', 'ciel': '#38bdf8', 'cyan': '#06b6d4', 'violet': '#8b5cf6', 'mauve': '#a855f7', 'indigo': '#6366f1', 'marron': '#92400e', 'beige': '#d4b896', 'creme': '#fef3c7', 'turquoise': '#14b8a6', 'or rose': '#fb7185', 'transparent': 'rgba(200,200,200,0.3)', 'naturel': '#f5f0e8', 'translucide': 'rgba(200,200,200,0.3)', } def css_color(value): if not value: return value return _FRENCH_COLORS.get(value.strip().lower(), value) app.jinja_env.filters['css_color'] = css_color # ─── Dashboard ──────────────────────────────────────────────────────────────── @app.route('/') def dashboard(): conn = get_db() recent = conn.execute(''' SELECT j.*, c.name as client_name, m.name as material_name FROM jobs j LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id ORDER BY j.created_at DESC LIMIT 10''').fetchall() stats = conn.execute(''' SELECT COUNT(*) as total_jobs, ROUND(SUM(final_price),2) as total_revenue, ROUND(AVG(gross_margin_pct),1) as avg_margin, ROUND(SUM(CASE WHEN created_at>=date('now','-30 days') THEN final_price ELSE 0 END),2) as month_revenue FROM jobs''').fetchone() low_stock = conn.execute( 'SELECT * FROM materials WHERE stock_g<=low_stock_threshold_g ORDER BY stock_g ASC').fetchall() # Restock alerts planned_consumption = conn.execute(''' SELECT j.material_id, SUM(j.weight_g * j.order_qty) as total_g FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.status='planned' AND j.material_id IS NOT NULL GROUP BY j.material_id''').fetchall() conn.close() return render_template('dashboard.html', jobs=recent, stats=stats, low_stock=low_stock, planned_consumption=planned_consumption) # ─── Clients ────────────────────────────────────────────────────────────────── @app.route('/clients') def clients(): conn = get_db() clients = conn.execute(''' SELECT c.*, COUNT(j.id) as job_count, ROUND(COALESCE(SUM(j.final_price),0),2) as total_revenue FROM clients c LEFT JOIN jobs j ON j.client_id=c.id GROUP BY c.id ORDER BY c.name''').fetchall() conn.close() return render_template('clients.html', clients=clients) def _get_all_profiles(conn): return { 'machine_profiles': conn.execute('SELECT id,name FROM machine_profiles ORDER BY name').fetchall(), 'handling_profiles': conn.execute('SELECT id,name FROM handling_profiles ORDER BY name').fetchall(), 'material_profiles': conn.execute('SELECT id,name FROM material_profiles ORDER BY name').fetchall(), 'pricing_profiles': conn.execute('SELECT id,name FROM pricing_profiles ORDER BY name').fetchall(), } @app.route('/clients/new', methods=['GET','POST']) def new_client(): conn = get_db() if request.method == 'POST': def _int(k): return int(request.form[k]) if request.form.get(k) else None conn.execute('''INSERT INTO clients (name,email,notes,default_machine_profile_id,default_handling_profile_id, default_material_profile_id,default_pricing_profile_id) VALUES(?,?,?,?,?,?,?)''', (request.form['name'], request.form.get('email',''), request.form.get('notes',''), _int('default_machine_profile_id'), _int('default_handling_profile_id'), _int('default_material_profile_id'), _int('default_pricing_profile_id'))) conn.commit(); conn.close() return redirect(url_for('clients')) profiles = _get_all_profiles(conn) conn.close() return render_template('client_form.html', client=None, **profiles) @app.route('/clients//edit', methods=['GET','POST']) def edit_client(id): conn = get_db() client = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone() if request.method == 'POST': def _int(k): return int(request.form[k]) if request.form.get(k) else None conn.execute('''UPDATE clients SET name=?,email=?,notes=?, default_machine_profile_id=?,default_handling_profile_id=?, default_material_profile_id=?,default_pricing_profile_id=? WHERE id=?''', (request.form['name'], request.form.get('email',''), request.form.get('notes',''), _int('default_machine_profile_id'), _int('default_handling_profile_id'), _int('default_material_profile_id'), _int('default_pricing_profile_id'), id)) conn.commit(); conn.close() return redirect(url_for('clients')) profiles = _get_all_profiles(conn) conn.close() return render_template('client_form.html', client=client, **profiles) @app.route('/api/client//defaults') def api_client_defaults(id): conn = get_db() c = conn.execute('SELECT default_machine_profile_id, default_handling_profile_id, \ default_material_profile_id, default_pricing_profile_id FROM clients WHERE id=?',(id,)).fetchone() conn.close() if not c: return jsonify({}) return jsonify({ 'machine_profile_id': c['default_machine_profile_id'], 'handling_profile_id': c['default_handling_profile_id'], 'material_profile_id': c['default_material_profile_id'], 'pricing_profile_id': c['default_pricing_profile_id'], }) @app.route('/clients//delete', methods=['POST']) def delete_client(id): conn = get_db() conn.execute('DELETE FROM clients WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('clients')) # ─── Projets ────────────────────────────────────────────────────────────────── @app.route('/projects') def projects(): conn = get_db() rows = conn.execute(''' SELECT p.*, c.name as client_name, COUNT(j.id) as job_count, COALESCE(SUM(j.order_qty), 0) as total_pieces, COALESCE(SUM(j.final_price * j.order_qty), 0) as total_revenue FROM projects p LEFT JOIN clients c ON c.id = p.client_id LEFT JOIN jobs j ON j.project_id = p.id GROUP BY p.id ORDER BY p.created_at DESC ''').fetchall() conn.close() return render_template('projects.html', projects=rows) @app.route('/projects/new', methods=['GET','POST']) def new_project(): conn = get_db() if request.method == 'POST': conn.execute('INSERT INTO projects(name,client_id,deadline,notes) VALUES(?,?,?,?)', (request.form['name'], int(request.form['client_id']) if request.form.get('client_id') else None, request.form.get('deadline') or None, request.form.get('notes',''))) conn.commit(); conn.close() return redirect(url_for('projects')) clients = conn.execute('SELECT id,name FROM clients ORDER BY name').fetchall() conn.close() return render_template('project_form.html', project=None, clients=clients) @app.route('/projects/', methods=['GET']) def project_detail(id): conn = get_db() project = conn.execute(''' SELECT p.*, c.name as client_name FROM projects p LEFT JOIN clients c ON c.id=p.client_id WHERE p.id=?''', (id,)).fetchone() if not project: conn.close(); return redirect(url_for('projects')) jobs = conn.execute(''' SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type FROM jobs j LEFT JOIN clients c ON c.id=j.client_id LEFT JOIN materials m ON m.id=j.material_id WHERE j.project_id=? ORDER BY j.created_at''', (id,)).fetchall() conn.close() total_pieces = sum((j['order_qty'] or 1) for j in jobs) total_revenue = sum((j['final_price'] or 0) * (j['order_qty'] or 1) for j in jobs) return render_template('project_detail.html', project=project, jobs=jobs, total_pieces=total_pieces, total_revenue=total_revenue) @app.route('/projects//edit', methods=['GET','POST']) def edit_project(id): conn = get_db() project = conn.execute('SELECT * FROM projects WHERE id=?',(id,)).fetchone() if request.method == 'POST': conn.execute('UPDATE projects SET name=?,client_id=?,deadline=?,notes=? WHERE id=?', (request.form['name'], int(request.form['client_id']) if request.form.get('client_id') else None, request.form.get('deadline') or None, request.form.get('notes',''), id)) conn.commit(); conn.close() return redirect(url_for('project_detail', id=id)) clients = conn.execute('SELECT id,name FROM clients ORDER BY name').fetchall() conn.close() return render_template('project_form.html', project=project, clients=clients) @app.route('/projects//delete', methods=['POST']) def delete_project(id): conn = get_db() conn.execute('UPDATE jobs SET project_id=NULL WHERE project_id=?',(id,)) conn.execute('DELETE FROM projects WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('projects')) # ─── Matières ───────────────────────────────────────────────────────────────── @app.route('/materials') def materials(): conn = get_db() mats = conn.execute(''' SELECT m.*, COUNT(j.id) as job_count, ROUND(COALESCE(SUM(j.weight_g),0),1) as total_used_g FROM materials m LEFT JOIN jobs j ON j.material_id=m.id GROUP BY m.id ORDER BY m.type, m.name''').fetchall() types = sorted(set(m['type'] for m in mats if m['type'])) conn.close() return render_template('materials.html', materials=mats, types=types) @app.route('/materials/new', methods=['GET','POST']) def new_material(): if request.method == 'POST': price = float(request.form['price_per_kg']) conn = get_db() conn.execute('''INSERT INTO materials(name,brand,type,color,price_per_kg,stock_g,low_stock_threshold_g,notes) VALUES(?,?,?,?,?,?,?,?)''', (request.form['name'], request.form.get('brand',''), request.form.get('type','PLA'), request.form.get('color',''), price, float(request.form.get('stock_g',0)), float(request.form.get('low_stock_threshold_g',200)), request.form.get('notes',''))) mat_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0] conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (mat_id, price)) conn.commit(); conn.close() return redirect(url_for('materials')) return render_template('material_form.html', material=None, history=None) @app.route('/materials//edit', methods=['GET','POST']) def edit_material(id): conn = get_db() mat = conn.execute('SELECT * FROM materials WHERE id=?',(id,)).fetchone() history = conn.execute( 'SELECT * FROM material_price_history WHERE material_id=? ORDER BY recorded_at DESC LIMIT 10', (id,)).fetchall() if request.method == 'POST': new_price = float(request.form['price_per_kg']) old_price = mat['price_per_kg'] conn.execute('''UPDATE materials SET name=?,brand=?,type=?,color=?,price_per_kg=?, stock_g=?,low_stock_threshold_g=?,notes=? WHERE id=?''', (request.form['name'], request.form.get('brand',''), request.form.get('type','PLA'), request.form.get('color',''), new_price, float(request.form.get('stock_g', mat['stock_g'])), float(request.form.get('low_stock_threshold_g',200)), request.form.get('notes',''), id)) if abs(new_price - old_price) > 0.001: conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (id, new_price)) conn.commit(); conn.close() return redirect(url_for('materials')) conn.close() return render_template('material_form.html', material=mat, history=history) @app.route('/materials//restock', methods=['POST']) def restock_material(id): conn = get_db() add_g = float(request.form.get('add_g', 0)) conn.execute('UPDATE materials SET stock_g=stock_g+? WHERE id=?', (add_g, id)) new_price = request.form.get('new_price_per_kg') if new_price: new_price = float(new_price) mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(id,)).fetchone() conn.execute('UPDATE materials SET price_per_kg=? WHERE id=?', (new_price, id)) if abs(new_price - mat['price_per_kg']) > 0.001: conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (id, new_price)) conn.commit(); conn.close() return redirect(url_for('materials')) @app.route('/materials//duplicate', methods=['POST']) def duplicate_material(id): conn = get_db() m = conn.execute('SELECT * FROM materials WHERE id=?',(id,)).fetchone() if m: conn.execute('''INSERT INTO materials(name,brand,type,color,price_per_kg,stock_g,low_stock_threshold_g,notes) VALUES(?,?,?,?,?,?,?,?)''', ('Copie de ' + m['name'], m['brand'], m['type'], m['color'], m['price_per_kg'], 0, m['low_stock_threshold_g'], m['notes'])) new_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0] conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (new_id, m['price_per_kg'])) conn.commit(); conn.close() return redirect(url_for('edit_material', id=new_id)) conn.close() return redirect(url_for('materials')) @app.route('/materials//delete', methods=['POST']) def delete_material(id): conn = get_db() conn.execute('DELETE FROM materials WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('materials')) # ─── Profils / Templates ────────────────────────────────────────────────────── @app.route('/profiles') def profiles(): conn = get_db() machine = conn.execute('SELECT * FROM machine_profiles ORDER BY name').fetchall() handling = conn.execute('SELECT * FROM handling_profiles ORDER BY name').fetchall() material = conn.execute('SELECT * FROM material_profiles ORDER BY name').fetchall() pricing = conn.execute('SELECT * FROM pricing_profiles ORDER BY name').fetchall() conn.close() return render_template('profiles.html', machine_profiles=machine, handling_profiles=handling, material_profiles=material, pricing_profiles=pricing) @app.route('/profiles/machine/new', methods=['GET','POST']) def new_machine_profile(): if request.method == 'POST': conn = get_db() conn.execute('''INSERT INTO machine_profiles (name,printer_price,printer_lifespan_hours,nozzle_price,nozzle_lifespan_hours, plate_price,plate_lifespan_hours,printer_power_kw,electricity_price_kwh,notes) VALUES(?,?,?,?,?,?,?,?,?,?)''', ( request.form['name'], float(request.form['printer_price']), float(request.form['printer_lifespan_hours']), float(request.form['nozzle_price']), float(request.form['nozzle_lifespan_hours']), float(request.form['plate_price']), float(request.form['plate_lifespan_hours']), float(request.form['printer_power_kw']), float(request.form['electricity_price_kwh']), request.form.get('notes','') )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#machine') return render_template('machine_profile_form.html', profile=None, settings=get_settings()) @app.route('/profiles/machine//edit', methods=['GET','POST']) def edit_machine_profile(id): conn = get_db() profile = conn.execute('SELECT * FROM machine_profiles WHERE id=?',(id,)).fetchone() if request.method == 'POST': conn.execute('''UPDATE machine_profiles SET name=?,printer_price=?,printer_lifespan_hours=?,nozzle_price=?,nozzle_lifespan_hours=?, plate_price=?,plate_lifespan_hours=?,printer_power_kw=?,electricity_price_kwh=?,notes=? WHERE id=?''', ( request.form['name'], float(request.form['printer_price']), float(request.form['printer_lifespan_hours']), float(request.form['nozzle_price']), float(request.form['nozzle_lifespan_hours']), float(request.form['plate_price']), float(request.form['plate_lifespan_hours']), float(request.form['printer_power_kw']), float(request.form['electricity_price_kwh']), request.form.get('notes',''), id )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#machine') conn.close() return render_template('machine_profile_form.html', profile=profile, settings=get_settings()) @app.route('/profiles/machine//delete', methods=['POST']) def delete_machine_profile(id): conn = get_db() conn.execute('DELETE FROM machine_profiles WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('profiles') + '#machine') @app.route('/profiles/handling/new', methods=['GET','POST']) def new_handling_profile(): if request.method == 'POST': conn = get_db() conn.execute('INSERT INTO handling_profiles(name,handling_rate_per_hour,handling_minutes_per_plate,notes) VALUES(?,?,?,?)', ( request.form['name'], float(request.form['handling_rate_per_hour']), float(request.form['handling_minutes_per_plate']), request.form.get('notes','') )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#handling') return render_template('handling_profile_form.html', profile=None, settings=get_settings()) @app.route('/profiles/handling//edit', methods=['GET','POST']) def edit_handling_profile(id): conn = get_db() profile = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(id,)).fetchone() if request.method == 'POST': conn.execute('UPDATE handling_profiles SET name=?,handling_rate_per_hour=?,handling_minutes_per_plate=?,notes=? WHERE id=?', ( request.form['name'], float(request.form['handling_rate_per_hour']), float(request.form['handling_minutes_per_plate']), request.form.get('notes',''), id )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#handling') conn.close() return render_template('handling_profile_form.html', profile=profile, settings=get_settings()) @app.route('/profiles/handling//delete', methods=['POST']) def delete_handling_profile(id): conn = get_db() conn.execute('DELETE FROM handling_profiles WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('profiles') + '#handling') @app.route('/profiles/material/new', methods=['GET','POST']) def new_material_profile(): if request.method == 'POST': conn = get_db() conn.execute('INSERT INTO material_profiles(name,material_margin_pct,notes) VALUES(?,?,?)', ( request.form['name'], float(request.form['material_margin_pct']), request.form.get('notes','') )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#material') return render_template('material_profile_form.html', profile=None, settings=get_settings()) @app.route('/profiles/material//edit', methods=['GET','POST']) def edit_material_profile(id): conn = get_db() profile = conn.execute('SELECT * FROM material_profiles WHERE id=?',(id,)).fetchone() if request.method == 'POST': conn.execute('UPDATE material_profiles SET name=?,material_margin_pct=?,notes=? WHERE id=?', ( request.form['name'], float(request.form['material_margin_pct']), request.form.get('notes',''), id )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#material') conn.close() return render_template('material_profile_form.html', profile=profile, settings=get_settings()) @app.route('/profiles/material//delete', methods=['POST']) def delete_material_profile(id): conn = get_db() conn.execute('DELETE FROM material_profiles WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('profiles') + '#material') @app.route('/profiles/pricing/new', methods=['GET','POST']) def new_pricing_profile(): if request.method == 'POST': conn = get_db() conn.execute('INSERT INTO pricing_profiles(name,design_multiplier,gross_margin_pct,notes) VALUES(?,?,?,?)', ( request.form['name'], float(request.form['design_multiplier']), float(request.form['gross_margin_pct']), request.form.get('notes','') )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#pricing') return render_template('pricing_profile_form.html', profile=None, settings=get_settings()) @app.route('/profiles/pricing//edit', methods=['GET','POST']) def edit_pricing_profile(id): conn = get_db() profile = conn.execute('SELECT * FROM pricing_profiles WHERE id=?',(id,)).fetchone() if request.method == 'POST': conn.execute('UPDATE pricing_profiles SET name=?,design_multiplier=?,gross_margin_pct=?,notes=? WHERE id=?', ( request.form['name'], float(request.form['design_multiplier']), float(request.form['gross_margin_pct']), request.form.get('notes',''), id )) conn.commit(); conn.close() return redirect(url_for('profiles') + '#pricing') conn.close() return render_template('pricing_profile_form.html', profile=profile, settings=get_settings()) @app.route('/profiles/pricing//delete', methods=['POST']) def delete_pricing_profile(id): conn = get_db() conn.execute('DELETE FROM pricing_profiles WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('profiles') + '#pricing') # ─── Commandes ──────────────────────────────────────────────────────────────── @app.route('/jobs') def jobs(): conn = get_db() jobs = conn.execute(''' SELECT j.*, c.name as client_name, m.name as material_name, ps.status as slot_status, ps.planned_start FROM jobs j LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id LEFT JOIN print_slots ps ON ps.job_id=j.id AND ps.id=( SELECT id FROM print_slots WHERE job_id=j.id ORDER BY planned_start LIMIT 1) ORDER BY j.created_at DESC''').fetchall() conn.close() return render_template('jobs.html', jobs=jobs) @app.route('/jobs/new', methods=['GET','POST']) def new_job(): conn = get_db() clients = conn.execute('SELECT * FROM clients ORDER BY name').fetchall() mats = conn.execute('SELECT * FROM materials ORDER BY type,name').fetchall() machine_profiles = conn.execute('SELECT * FROM machine_profiles ORDER BY name').fetchall() handling_profiles = conn.execute('SELECT * FROM handling_profiles ORDER BY name').fetchall() material_profiles = conn.execute('SELECT * FROM material_profiles ORDER BY name').fetchall() pricing_profiles = conn.execute('SELECT * FROM pricing_profiles ORDER BY name').fetchall() projects = conn.execute('SELECT id,name FROM projects ORDER BY name').fetchall() settings = get_settings() # Pré-remplissage depuis un job existant prefill = None clone_from = request.args.get('clone_from') if clone_from: prefill = conn.execute('SELECT * FROM jobs WHERE id=?', (clone_from,)).fetchone() if prefill: 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']) hours = int(request.form.get('hours',0)) minutes = int(request.form.get('minutes',0)) print_time_s = hours*3600 + minutes*60 design_mult = float(request.form['design_multiplier']) gross_margin = float(request.form['gross_margin_pct']) discount = float(request.form.get('discount_pct',0)) client_id = request.form.get('client_id') or None material_id = request.form.get('material_id') or None source_file = request.form.get('source_file','').strip() machine_pid = request.form.get('machine_profile_id') or None handling_pid = request.form.get('handling_profile_id') or None material_pid = request.form.get('material_profile_id') or None pricing_pid = request.form.get('pricing_profile_id') or None project_id = request.form.get('project_id') or None 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: mp = conn.execute('SELECT * FROM machine_profiles WHERE id=?',(machine_pid,)).fetchone() if mp: s['printer_price'] = mp['printer_price'] s['printer_lifespan_hours'] = mp['printer_lifespan_hours'] s['nozzle_price'] = mp['nozzle_price'] s['nozzle_lifespan_hours'] = mp['nozzle_lifespan_hours'] s['plate_price'] = mp['plate_price'] s['plate_lifespan_hours'] = mp['plate_lifespan_hours'] s['printer_power_kw'] = mp['printer_power_kw'] s['electricity_price_kwh'] = mp['electricity_price_kwh'] if handling_pid: hp = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(handling_pid,)).fetchone() if hp: s['handling_rate_per_hour'] = hp['handling_rate_per_hour'] s['handling_minutes_per_plate'] = hp['handling_minutes_per_plate'] if material_pid: marp = conn.execute('SELECT * FROM material_profiles WHERE id=?',(material_pid,)).fetchone() if marp: s['material_margin_pct'] = marp['material_margin_pct'] price_kg = None if material_id: mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(material_id,)).fetchone() if mat: price_kg = mat['price_per_kg'] 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,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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id, 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'], r['wear_cost'],r['electricity_cost'], r['cout_fixe'],r['total_marge'],r['subtotal'],r['margin_amount'],r['marge_pct_on_ht'], r['cotisations_amount'],r['vfl_amount'],r['tva_amount'],r['other_taxes_amount'], r['tax_amount'],r['final_price'],round(r['final_price'],2), request.form.get('notes',''))) new_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0] if material_id: conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?', (weight_g, material_id)) conn.commit(); conn.close() if request.form.get('action') == 'save_and_new': return redirect(url_for('new_job', clone_from=new_id)) return redirect(url_for('jobs')) conn.close() return render_template('new_job.html', clients=clients, materials=mats, settings=settings, machine_profiles=machine_profiles, handling_profiles=handling_profiles, material_profiles=material_profiles, pricing_profiles=pricing_profiles, projects=projects, prefill=prefill) @app.route('/jobs/') def job_detail(id): conn = get_db() job = conn.execute(''' SELECT j.*, c.name as client_name, m.name as material_name, m.color as material_color, m.type as material_type, mp.name as machine_profile_name, hp.name as handling_profile_name, marp.name as material_profile_name, pp.name as pricing_profile_name FROM jobs j LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id LEFT JOIN machine_profiles mp ON j.machine_profile_id=mp.id LEFT JOIN handling_profiles hp ON j.handling_profile_id=hp.id LEFT JOIN material_profiles marp ON j.material_profile_id=marp.id LEFT JOIN pricing_profiles pp ON j.pricing_profile_id=pp.id WHERE j.id=?''', (id,)).fetchone() slots = conn.execute(''' SELECT ps.*, p.name as printer_name, p.ha_entity_prefix, COALESCE(ps.pieces_override, j.pieces_per_plate) as effective_pieces, COALESCE(ps.pieces_ignored, 0) as pieces_ignored FROM print_slots ps LEFT JOIN printers p ON ps.printer_id=p.id JOIN jobs j ON ps.job_id=j.id WHERE ps.job_id=? ORDER BY ps.planned_start''', (id,)).fetchall() conn.close() if not job: return redirect(url_for('jobs')) import math as _math total_plates_needed = _math.ceil((job['order_qty'] or 1) / max(1, job['pieces_per_plate'] or 1)) _s = get_settings() ha_poll_interval = int(_s.get('ha_poll_interval', 60)) return render_template('job_detail.html', job=job, slots=slots, total_plates_needed=total_plates_needed, ha_poll_interval=ha_poll_interval) @app.route('/jobs//delete', methods=['POST']) def delete_job(id): conn = get_db() conn.execute('DELETE FROM jobs WHERE id=?',(id,)) conn.commit(); conn.close() return redirect(url_for('jobs')) # ─── Planning ───────────────────────────────────────────────────────────────── @app.route('/planning') def planning(): conn = get_db() printers = [dict(r) for r in conn.execute('SELECT * FROM printers ORDER BY id').fetchall()] unscheduled = conn.execute(''' SELECT j.*, c.name as client_name, COALESCE((SELECT COUNT(*) FROM print_slots ps WHERE ps.job_id=j.id AND ps.status NOT IN ('cancelled','failed')), 0) as scheduled_plates, (COALESCE(j.order_qty,1) + COALESCE(j.pieces_per_plate,1) - 1) / COALESCE(j.pieces_per_plate,1) as total_plates_needed FROM jobs j LEFT JOIN clients c ON j.client_id=c.id WHERE (COALESCE(j.order_qty,1) + COALESCE(j.pieces_per_plate,1) - 1) / COALESCE(j.pieces_per_plate,1) > COALESCE((SELECT COUNT(*) FROM print_slots ps WHERE ps.job_id=j.id AND ps.status NOT IN ('cancelled','failed')), 0) ORDER BY j.created_at DESC LIMIT 20''').fetchall() blocks = conn.execute( "SELECT * FROM calendar_blocks" " WHERE COALESCE(date_end, date) >= date('now') ORDER BY date LIMIT 60" ).fetchall() schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() conn.close() settings = get_settings() return render_template('planning.html', printers=printers, unscheduled=unscheduled, blocks=blocks, schedule=schedule, settings=settings) @app.route('/printers', methods=['GET','POST']) def printers_page(): conn = get_db() if request.method == 'POST': name = request.form['name'] active = 1 if request.form.get('is_active') else 0 notes = request.form.get('notes','') ha_prefix = request.form.get('ha_entity_prefix','').strip().lower() conn.execute('INSERT INTO printers(name,is_active,notes,ha_entity_prefix) VALUES(?,?,?,?)', (name, active, notes, ha_prefix)) conn.commit() printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall() conn.close() return render_template('printers.html', printers=printers) @app.route('/printers//toggle', methods=['POST']) def toggle_printer(id): conn = get_db() conn.execute('UPDATE printers SET is_active=1-is_active WHERE id=?', (id,)) conn.commit(); conn.close() return redirect(url_for('printers_page')) @app.route('/printers//delete', methods=['POST']) def delete_printer(id): conn = get_db() conn.execute('DELETE FROM printers WHERE id=?', (id,)) conn.commit(); conn.close() return redirect(url_for('printers_page')) @app.route('/printers//edit', methods=['POST']) @login_required def edit_printer(id): name = request.form.get('name','').strip() ha_prefix = request.form.get('ha_entity_prefix','').strip().lower() notes = request.form.get('notes','').strip() if name: conn = get_db() conn.execute('UPDATE printers SET name=?, ha_entity_prefix=?, notes=? WHERE id=?', (name, ha_prefix, notes, id)) conn.commit(); conn.close() return redirect(url_for('printers_page')) @app.route('/working-schedule', methods=['GET','POST']) def working_schedule(): if request.method == 'POST': conn = get_db() for dow in range(7): mode = request.form.get(f'mode_{dow}', 'off') tt_start = request.form.get(f'tt_start_{dow}', '10:00') tt_end = request.form.get(f'tt_end_{dow}', '22:00') onsite_slot1 = request.form.get(f'onsite_slot1_{dow}', '07:00') onsite_slot2 = request.form.get(f'onsite_slot2_{dow}', '18:30') conn.execute('''UPDATE working_schedule SET mode=?,tt_window_start=?,tt_window_end=?, onsite_slot_1=?,onsite_slot_2=? WHERE day_of_week=?''', (mode, tt_start, tt_end, onsite_slot1, onsite_slot2, dow)) conn.commit(); conn.close() return redirect(url_for('working_schedule')) conn = get_db() schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() conn.close() return render_template('working_schedule.html', schedule=schedule) @app.route('/calendar-blocks', methods=['GET','POST']) def calendar_blocks(): conn = get_db() if request.method == 'POST': date_start = request.form['date_start'] date_end = request.form.get('date_end','').strip() or None # If date_end < date_start, ignore it if date_end and date_end < date_start: date_end = None conn.execute( 'INSERT INTO calendar_blocks(date,date_end,type,printer_id,notes) VALUES(?,?,?,?,?)', (date_start, date_end, request.form.get('type','off'), request.form.get('printer_id') or None, request.form.get('notes','')) ) conn.commit() blocks = conn.execute("SELECT cb.*, p.name as printer_name FROM calendar_blocks cb LEFT JOIN printers p ON cb.printer_id=p.id ORDER BY cb.date DESC LIMIT 50").fetchall() printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall() conn.close() return render_template('calendar_blocks.html', blocks=blocks, printers=printers) @app.route('/calendar-blocks//delete', methods=['POST']) def delete_calendar_block(id): conn = get_db() conn.execute('DELETE FROM calendar_blocks WHERE id=?', (id,)) conn.commit(); conn.close() return redirect(url_for('calendar_blocks')) # ─── Page mobile saisie rapide ──────────────────────────────────────────────── @app.route('/mobile', methods=['GET', 'POST']) def mobile_quick(): conn = get_db() if request.method == 'POST': action = request.form.get('action') if action == 'block': conn.execute( 'INSERT INTO calendar_blocks(date,type,printer_id,notes) VALUES(?,?,?,?)', (request.form['date'], request.form.get('type', 'off'), request.form.get('printer_id') or None, request.form.get('notes', ''))) conn.commit() elif action == 'shift': slot_id = request.form.get('slot_id') new_start = request.form.get('new_start') if slot_id and new_start: slot = conn.execute('SELECT * FROM print_slots WHERE id=?', (slot_id,)).fetchone() if slot: try: orig_s = datetime.fromisoformat(slot['planned_start']) orig_e = datetime.fromisoformat(slot['planned_end']) new_s = datetime.fromisoformat(new_start) delta = new_s - orig_s new_e = (orig_e + delta).isoformat() conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?', (new_s.isoformat(), new_e, slot_id)) conn.commit() except Exception: pass elif action == 'fail': slot_id = request.form.get('slot_id') note = request.form.get('failure_note', '') wasted_g = float(request.form.get('wasted_g', 0) or 0) if slot_id: slot = conn.execute(''' SELECT ps.weight_g as plate_g, j.material_id, j.weight_g FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?''', (slot_id,)).fetchone() if slot: if wasted_g == 0: wasted_g = slot['weight_g'] or 0 conn.execute( "UPDATE print_slots SET status='failed', failure_note=?, wasted_g=? WHERE id=?", (note, wasted_g, slot_id)) if slot['material_id'] and wasted_g > 0: conn.execute( 'UPDATE materials SET stock_g=MAX(0,stock_g-?), total_wasted_g=COALESCE(total_wasted_g,0)+? WHERE id=?', (wasted_g, wasted_g, slot['material_id'])) conn.commit() elif action == 'done': slot_id = request.form.get('slot_id') actual_g = float(request.form.get('actual_g', 0) or 0) if slot_id: slot = conn.execute( 'SELECT j.material_id FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?', (slot_id,)).fetchone() conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (slot_id,)) if slot and slot['material_id'] and actual_g > 0: conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?', (actual_g, slot['material_id'])) conn.commit() conn.close() return redirect(url_for('mobile_quick')) printers = conn.execute('SELECT * FROM printers WHERE is_active=1 ORDER BY id').fetchall() today = date_cls.today().isoformat() upcoming = conn.execute(''' SELECT ps.*, j.name as job_name, p.name as printer_name, c.name as client_name FROM print_slots ps JOIN jobs j ON ps.job_id=j.id JOIN printers p ON ps.printer_id=p.id LEFT JOIN clients c ON j.client_id=c.id WHERE ps.status='planned' AND date(ps.planned_start) >= ? ORDER BY ps.planned_start LIMIT 20''', (today,)).fetchall() conn.close() return render_template('mobile.html', printers=printers, upcoming=upcoming, today=today) # ─── Gestion échecs impression ──────────────────────────────────────────────── @app.route('/api/slots//fail', methods=['POST']) def fail_slot(id): """Marque un créneau comme échoué, log le gaspillage filament.""" conn = get_db() slot = conn.execute(''' SELECT ps.*, j.weight_g, j.material_id, j.pieces_per_plate FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?''', (id,)).fetchone() if not slot: conn.close() return jsonify({'error': 'Créneau introuvable'}), 404 note = request.json.get('note', '') if request.is_json else request.form.get('note', '') wasted_g = float(request.json.get('wasted_g', 0) if request.is_json else request.form.get('wasted_g', 0) or 0) # Si wasted_g non précisé, estimer = poids plateau complet if wasted_g == 0 and slot['weight_g']: wasted_g = slot['weight_g'] conn.execute("""UPDATE print_slots SET status='failed', failure_note=?, wasted_g=? WHERE id=?""", (note, wasted_g, id)) # Déduire du stock matière si lié if slot['material_id'] and wasted_g > 0: conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?), total_wasted_g = COALESCE(total_wasted_g,0) + ? WHERE id=?', (wasted_g, wasted_g, slot['material_id'])) conn.commit(); conn.close() return jsonify({'ok': True, 'wasted_g': wasted_g}) @app.route('/api/slots//done', methods=['POST']) def done_slot(id): """Marque un créneau comme terminé avec le poids réel consommé.""" conn = get_db() slot = conn.execute(''' SELECT ps.*, j.material_id FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?''', (id,)).fetchone() if not slot: conn.close() return jsonify({'error': 'Créneau introuvable'}), 404 actual_g = float(request.json.get('actual_g', 0) if request.is_json else request.form.get('actual_g', 0) or 0) conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (id,)) # Déduire le poids réel consommé du stock if slot['material_id'] and actual_g > 0: conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?) WHERE id=?', (actual_g, slot['material_id'])) conn.commit(); conn.close() return jsonify({'ok': True}) # ─── API ────────────────────────────────────────────────────────────────────── @app.route('/api/calculate', methods=['POST']) def api_calculate(): d = request.json s = dict(get_settings()) conn = get_db() if d.get('machine_profile_id'): mp = conn.execute('SELECT * FROM machine_profiles WHERE id=?',(d['machine_profile_id'],)).fetchone() if mp: s['printer_price'] = mp['printer_price'] s['printer_lifespan_hours'] = mp['printer_lifespan_hours'] s['nozzle_price'] = mp['nozzle_price'] s['nozzle_lifespan_hours'] = mp['nozzle_lifespan_hours'] s['plate_price'] = mp['plate_price'] s['plate_lifespan_hours'] = mp['plate_lifespan_hours'] s['printer_power_kw'] = mp['printer_power_kw'] s['electricity_price_kwh'] = mp['electricity_price_kwh'] if d.get('handling_profile_id'): hp = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(d['handling_profile_id'],)).fetchone() if hp: s['handling_rate_per_hour'] = hp['handling_rate_per_hour'] s['handling_minutes_per_plate'] = hp['handling_minutes_per_plate'] if d.get('material_profile_id'): marp = conn.execute('SELECT * FROM material_profiles WHERE id=?',(d['material_profile_id'],)).fetchone() if marp: s['material_margin_pct'] = marp['material_margin_pct'] price_kg = None if d.get('material_id'): mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(d['material_id'],)).fetchone() if mat: price_kg = mat['price_per_kg'] conn.close() pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1))) order_qty = max(1, int(d.get('order_qty', 1))) print_time_s = int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60 result = calc( weight_g=float(d['weight_g']), print_time_s=print_time_s, design_mult=float(d.get('design_multiplier',1.80)), gross_margin_pct=float(d.get('gross_margin_pct',27.5)), discount_pct=float(d.get('discount_pct',0)), price_per_kg_override=price_kg, s=s, pieces_per_plate=pieces_per_plate) # Multi-piece extras nb_plateaux = math.ceil(order_qty / pieces_per_plate) price_order = round(result['final_price'] * order_qty, 2) last_plate_qty = order_qty % pieces_per_plate plate_full = last_plate_qty == 0 cooldown_min = s.get('cooldown_minutes', 15) total_print_min = (print_time_s / 60 + cooldown_min) * nb_plateaux - cooldown_min capacity = calc_capacity(print_time_s, pieces_per_plate) result['order_qty'] = order_qty result['nb_plateaux'] = nb_plateaux result['price_order'] = price_order result['plate_full'] = plate_full result['last_plate_qty'] = last_plate_qty result['total_print_min']= round(total_print_min, 0) result['capacity'] = capacity return jsonify(result) @app.route('/api/clients/') def api_client(id): conn = get_db() c = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone() conn.close() if not c: return jsonify({}), 404 return jsonify({'design_multiplier': c['design_multiplier']}) @app.route('/api/parse-3mf', methods=['POST']) def api_parse_3mf(): if 'file' not in request.files: return jsonify({'error': 'Aucun fichier'}), 400 f = request.files['file'] if not f.filename.lower().endswith('.3mf'): return jsonify({'error': 'Format .3mf requis'}), 400 result = parse_3mf(f.stream) # Nettoyer .gcode.3mf ou .3mf du nom clean = re.sub(r'\.gcode\.3mf$', '', f.filename, flags=re.IGNORECASE) clean = re.sub(r'\.3mf$', '', clean, flags=re.IGNORECASE) result['filename'] = clean # Si une seule plaque, pré-sélectionner automatiquement if result['plates'] and len(result['plates']) == 1: p = result['plates'][0] result['weight_g'] = p['weight_g'] result['hours'] = p['print_time_s'] // 3600 result['minutes'] = (p['print_time_s'] % 3600) // 60 return jsonify(result) @app.route('/api/slots') def api_slots(): conn = get_db() slots = conn.execute(''' SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end, ps.status, ps.failure_note, COALESCE(ps.pieces_ignored, 0) as pieces_ignored, ps.pieces_override, COALESCE(ps.pieces_override, j.pieces_per_plate) as effective_pieces, (SELECT COUNT(*) FROM print_slots ps2 WHERE ps2.job_id = ps.job_id AND ps2.planned_start <= ps.planned_start AND ps2.id <= ps.id) as plate_num, (SELECT COUNT(*) FROM print_slots WHERE job_id = ps.job_id) as total_plates, (SELECT COALESCE(SUM(COALESCE(pieces_ignored,0)), 0) FROM print_slots WHERE job_id = ps.job_id) as job_total_ignored, j.name as job_name, j.print_time_s, j.final_price, j.pieces_per_plate, j.order_qty, j.weight_g, j.plate_name, p.name as printer_name, c.name as client_name, m.name as material_name, m.type as material_type, m.color as material_color FROM print_slots ps JOIN jobs j ON ps.job_id=j.id JOIN printers p ON ps.printer_id=p.id LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id WHERE ps.planned_start >= datetime('now', '-7 days') ORDER BY ps.planned_start''').fetchall() conn.close() return jsonify([dict(s) for s in slots]) @app.route('/api/slots//pieces_override', methods=['POST']) @login_required def api_slot_pieces_override(id): d = request.get_json(silent=True) or {} pieces = d.get('pieces') conn = get_db() if pieces is None: conn.execute('UPDATE print_slots SET pieces_override=NULL WHERE id=?', (id,)) else: conn.execute('UPDATE print_slots SET pieces_override=? WHERE id=?', (max(1, int(pieces)), id)) conn.commit() conn.close() return jsonify({'ok': True}) def _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=None): """ 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') AND planned_start < ? AND planned_end > ? AND planned_end > datetime('now')""" params = [end_dt.isoformat(), start_dt.isoformat()] if exclude_id: q += ' AND id != ?' params.append(exclude_id) overlapping = conn.execute(q, params).fetchall() if len(overlapping) < max_printers: return False, len(overlapping) # pas assez de chevauchements pour atteindre la limite # Points à tester : le début du créneau proposé + début de chaque slot existant dans la fenêtre event_times = [start_dt] for sl in overlapping: t = datetime.fromisoformat(sl['planned_start']) if start_dt <= t < end_dt: event_times.append(t) peak = 0 for t in event_times: count = sum( 1 for sl in overlapping if datetime.fromisoformat(sl['planned_start']) <= t < datetime.fromisoformat(sl['planned_end']) ) if count > peak: peak = count if count >= max_printers: return True, count return False, peak def _check_overlap(conn, printer_id, start_dt, end_dt, exclude_id=None): """Retourne le slot en conflit s'il existe, sinon None.""" q = """SELECT ps.id, j.name as job_name, ps.planned_start, ps.planned_end FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.printer_id=? AND ps.status NOT IN ('cancelled','failed','done') AND ps.planned_start < ? AND ps.planned_end > ? AND ps.planned_end > datetime('now')""" params = [printer_id, end_dt.isoformat(), start_dt.isoformat()] if exclude_id: q += ' AND ps.id != ?' params.append(exclude_id) return conn.execute(q, params).fetchone() @app.route('/api/slots/new', methods=['POST']) def api_new_slot(): d = request.json job_id = int(d['job_id']) printer_id = int(d['printer_id']) start_str = d['planned_start'] # ISO string conn = get_db() job = conn.execute('SELECT print_time_s FROM jobs WHERE id=?', (job_id,)).fetchone() s = get_settings() if not job: conn.close() return jsonify({'error': 'Job introuvable'}), 404 cooldown_s = int(s.get('cooldown_minutes', 15)) * 60 start_dt = datetime.fromisoformat(start_str) end_dt = start_dt + timedelta(seconds=job['print_time_s'] + cooldown_s) already_done = bool(d.get('already_done', False)) if already_done: # Retroactive — skip capacity/overlap checks, insert as done conn.execute( 'INSERT INTO print_slots(job_id,printer_id,planned_start,planned_end,status) VALUES(?,?,?,?,?)', (job_id, printer_id, start_dt.isoformat(), end_dt.isoformat(), 'done')) conn.commit(); conn.close() return jsonify({'ok': True}) conflict = _check_overlap(conn, printer_id, start_dt, end_dt) if conflict: conn.close() end_conf = conflict['planned_end'][:16].replace('T', ' ') return jsonify({'error': f"Chevauchement avec « {conflict['job_name']} » (fin prévue {end_conf}). Choisissez un autre horaire ou une autre imprimante."}), 409 max_printers = int(s.get('max_simultaneous_printers', 5)) over, peak = _check_capacity(conn, start_dt, end_dt, max_printers) if over: conn.close() return jsonify({'error': f"Capacité maximale atteinte : {peak} impression(s) déjà en cours sur ce créneau (limite : {max_printers} simultanées). Décalez l'horaire ou attendez qu'une imprimante se libère."}), 409 conn.execute('INSERT INTO print_slots(job_id,printer_id,planned_start,planned_end,status) VALUES(?,?,?,?,?)', (job_id, printer_id, start_dt.isoformat(), end_dt.isoformat(), 'planned')) conn.commit(); conn.close() return jsonify({'ok': True}) @app.route('/api/slots//move', methods=['POST']) def api_move_slot(id): d = request.json start_str = d['planned_start'] conn = get_db() slot = conn.execute('SELECT ps.*, j.print_time_s FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?', (id,)).fetchone() if not slot: conn.close() return jsonify({'error': 'Slot introuvable'}), 404 s = get_settings() cooldown_s = int(s.get('cooldown_minutes', 15)) * 60 start_dt = datetime.fromisoformat(start_str) end_dt = start_dt + timedelta(seconds=slot['print_time_s'] + cooldown_s) conflict = _check_overlap(conn, slot['printer_id'], start_dt, end_dt, exclude_id=id) if conflict: conn.close() end_conf = conflict['planned_end'][:16].replace('T', ' ') return jsonify({'error': f"Chevauchement avec « {conflict['job_name']} » (fin prévue {end_conf})."}), 409 max_printers = int(s.get('max_simultaneous_printers', 5)) over, peak = _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=id) if over: conn.close() return jsonify({'error': f"Capacité maximale atteinte : {peak} impression(s) en cours sur ce créneau (limite : {max_printers} simultanées)."}), 409 conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?', (start_dt.isoformat(), end_dt.isoformat(), id)) conn.commit(); conn.close() return jsonify({'ok': True}) @app.route('/api/slots//status', methods=['POST']) def api_slot_status(id): d = request.json conn = get_db() conn.execute('UPDATE print_slots SET status=?,failure_note=? WHERE id=?', (d.get('status','planned'), d.get('note',''), id)) conn.commit(); conn.close() return jsonify({'ok': True}) @app.route('/api/slots//delete', methods=['POST']) def api_delete_slot(id): conn = get_db() conn.execute('DELETE FROM print_slots WHERE id=?', (id,)) conn.commit(); conn.close() return jsonify({'ok': True}) @app.route('/api/suggest-slot', methods=['POST']) def api_suggest_slot(): d = request.json print_time_s = int(d.get('print_time_s', 0)) result = find_next_slot(print_time_s) if result: return jsonify(result) return jsonify({'error': 'Aucun créneau disponible dans les 60 prochains jours'}), 404 @app.route('/api/capacity', methods=['POST']) def api_capacity(): d = request.json print_time_s = int(d.get('print_time_s', 0)) pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1))) result = calc_capacity(print_time_s, pieces_per_plate) return jsonify(result) @app.route('/api/availability') def api_availability(): """Returns unavailable periods for the next 4 weeks as JSON for vis.js background items.""" schedule = get_schedule_dict() today = date_cls.today() blocks = get_blocks_dict(today, today + timedelta(days=28)) result = [] for offset in range(28): d = today + timedelta(days=offset) date_str = str(d) block = next((b for b in blocks if b['date'] <= date_str and (b.get('date_end') or b['date']) >= date_str and b['printer_id'] is None), None) mode = block['type'] if block else schedule[d.weekday()]['mode'] if mode == 'off': result.append({ 'start': date_str + 'T00:00:00', 'end': date_str + 'T23:59:59', 'type': 'off' }) elif mode == 'tt': sched = schedule[d.weekday()] # Unavailable before tt_window_start result.append({'start': date_str+'T00:00:00', 'end': date_str+'T'+sched['tt_window_start']+':00', 'type':'unavailable'}) # Unavailable after tt_window_end result.append({'start': date_str+'T'+sched['tt_window_end']+':00', 'end': date_str+'T23:59:59', 'type':'unavailable'}) return jsonify(result) # ─── iCal Export ────────────────────────────────────────────────────────────── def _ical_escape(s): return str(s).replace('\\','\\\\').replace('\n','\\n').replace(',','\\,').replace(';','\\;') def _ical_dt(dt_str): return dt_str.replace('-','').replace(':','').replace(' ','T').split('.')[0] @app.route('/calendar.ics') def calendar_ics(): # Token optionnel : si ical_token est défini dans les settings, vérifier ?token=xxx conn = get_db() s_row = conn.execute("SELECT value FROM settings WHERE key='ical_token'").fetchone() ical_token = s_row['value'] if s_row else '' if ical_token and request.args.get('token', '') != ical_token: conn.close() return Response('Non autorisé', status=401) slots = conn.execute(''' SELECT ps.*, j.name as job_name, j.order_qty, j.pieces_per_plate, j.weight_g, j.notes as job_notes, p.name as printer_name, c.name as client_name FROM print_slots ps JOIN jobs j ON ps.job_id=j.id JOIN printers p ON ps.printer_id=p.id LEFT JOIN clients c ON j.client_id=c.id WHERE ps.status NOT IN ('cancelled') ORDER BY ps.planned_start''').fetchall() blocks = conn.execute(''' SELECT cb.*, p.name as printer_name FROM calendar_blocks cb LEFT JOIN printers p ON cb.printer_id=p.id ORDER BY cb.date''').fetchall() conn.close() now_str = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//3D Pricing//Planning//FR', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Planning Impression 3D', 'X-WR-TIMEZONE:Europe/Paris', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', ] # Créneaux d'impression for s in slots: start = _ical_dt(s['planned_start']) end = _ical_dt(s['planned_end']) client_part = f" — {s['client_name']}" if s['client_name'] else '' desc_parts = [ f"Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau", f"Statut: {s['status']}", ] if s['failure_note']: desc_parts.append(f"Note: {s['failure_note']}") if s['job_notes']: desc_parts.append(s['job_notes']) lines += [ 'BEGIN:VEVENT', f"DTSTART:{start}", f"DTEND:{end}", f"DTSTAMP:{now_str}", f"SUMMARY:{_ical_escape(s['job_name'] + client_part + ' [' + s['printer_name'] + ']')}", f"DESCRIPTION:{_ical_escape(' | '.join(desc_parts))}", f"UID:slot-{s['id']}@3d-pricing", f"STATUS:{'CONFIRMED' if s['status']=='done' else 'TENTATIVE'}", f"CATEGORIES:{'Terminé' if s['status']=='done' else 'Planifié'}", 'END:VEVENT', ] # Jours indisponibles / bloqués for b in blocks: date_val = b['date'].replace('-', '') # For DTEND: use date_end+1 if set, else date+1 (all-day exclusive end) end_src = b['date_end'] if b['date_end'] else b['date'] d_obj = datetime.strptime(end_src, '%Y-%m-%d') + timedelta(days=1) end_val = d_obj.strftime('%Y%m%d') label_map = {'off': '🔴 Jour off', 'maintenance': '🔧 Maintenance', 'other': '📌 Bloqué'} label = label_map.get(b['type'], b['type']) if b['printer_name']: label += f" — {b['printer_name']}" notes = b['notes'] or '' lines += [ 'BEGIN:VEVENT', f"DTSTART;VALUE=DATE:{date_val}", f"DTEND;VALUE=DATE:{end_val}", f"DTSTAMP:{now_str}", f"SUMMARY:{_ical_escape(label)}", f"DESCRIPTION:{_ical_escape(notes)}", f"UID:block-{b['id']}@3d-pricing", 'TRANSP:TRANSPARENT', 'STATUS:CONFIRMED', 'END:VEVENT', ] lines.append('END:VCALENDAR') ical = '\r\n'.join(lines) return Response(ical, mimetype='text/calendar; charset=utf-8', headers={'Content-Disposition': 'inline; filename=planning_3d.ics'}) # ─── Home Assistant — Sync ──────────────────────────────────────────────────── def _ha_fetch_all_states(ha_url, ha_token): """Retourne dict {entity_id: state_str} depuis l'API HA.""" if not _requests: return {} headers = {'Authorization': f'Bearer {ha_token}'} r = _requests.get(f'{ha_url}/api/states', headers=headers, timeout=15) if r.status_code != 200: return {} return {s['entity_id']: s['state'] for s in r.json()} def _ha_sync_once(): """Poll HA et met a jour les print_slots actifs.""" try: # Use direct connection with timeout — avoids locking conflicts with Flask request threads _conn = sqlite3.connect(DATABASE, timeout=15) _conn.row_factory = sqlite3.Row try: _s = _conn.execute("SELECT key, value FROM settings").fetchall() finally: _conn.close() s = {r['key']: r['value'] for r in _s} ha_url = str(s.get('ha_url', '')).rstrip('/') ha_token = str(s.get('ha_token', '')) if not ha_url or not ha_token: return states = _ha_fetch_all_states(ha_url, ha_token) if not states: return # Lire les imprimantes avec une connexion courte (lecture seule) _rc = sqlite3.connect(DATABASE, timeout=15) _rc.row_factory = sqlite3.Row printers = _rc.execute( "SELECT id, name, ha_entity_prefix FROM printers " "WHERE ha_entity_prefix IS NOT NULL AND ha_entity_prefix != ''" ).fetchall() _rc.close() now_iso = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() window = (datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=30)).isoformat() for p in printers: prefix = p['ha_entity_prefix'] ha_status = states.get(f'sensor.{prefix}_etat_de_l_impression', 'unknown') ha_error = states.get(f'binary_sensor.{prefix}_erreur_d_impression', 'off') # Connexion courte par imprimante : on lit, on écrit, on commit, on ferme wc = sqlite3.connect(DATABASE, timeout=15, isolation_level=None) wc.row_factory = sqlite3.Row try: slot = wc.execute( "SELECT id, status FROM print_slots " "WHERE printer_id=? AND status IN ('planned','running') " "AND planned_start <= ? AND planned_end >= ? " "ORDER BY planned_start DESC LIMIT 1", (p['id'], window, now_iso) ).fetchone() if not slot: continue new_status = None if ha_error == 'on': new_status = 'failed' elif ha_status == 'finish': new_status = 'done' elif ha_status == 'running' and slot['status'] == 'planned': new_status = 'running' if new_status: wc.execute('UPDATE print_slots SET status=? WHERE id=?', (new_status, slot['id'])) if ha_ignored > 0: wc.execute('UPDATE print_slots SET pieces_ignored=? WHERE id=?', (ha_ignored, slot['id'])) finally: wc.close() except Exception as e: print(f'[HA sync] {e}') def _start_ha_poll(): """Lance le thread de polling HA en arriere-plan.""" def _loop(): time.sleep(10) while True: _ha_sync_once() try: _c = sqlite3.connect(DATABASE, timeout=5) _c.row_factory = sqlite3.Row _interval = int(_c.execute("SELECT value FROM settings WHERE key='ha_poll_interval'").fetchone()['value']) _c.close() except Exception: _interval = 60 time.sleep(max(10, _interval)) t = threading.Thread(target=_loop, daemon=True, name='ha-poll') t.start() @app.route('/api/ha/status') @login_required def api_ha_status(): """Retourne le statut live HA pour toutes les imprimantes configurees.""" s = get_settings() ha_url = str(s.get('ha_url', '')).rstrip('/') ha_token = str(s.get('ha_token', '')) if not ha_url or not ha_token: return jsonify({'error': 'HA non configure', 'printers': {}}) try: if not _requests: return jsonify({'error': 'requests non installe', 'printers': {}}) headers = {'Authorization': f'Bearer {ha_token}'} r = _requests.get(f'{ha_url}/api/states', headers=headers, timeout=10) if r.status_code != 200: return jsonify({'error': f'HA HTTP {r.status_code}', 'printers': {}}) raw_states = r.json() states = {st['entity_id']: st['state'] for st in raw_states} conn = get_db() printers = conn.execute( "SELECT id, name, ha_entity_prefix FROM printers " "WHERE ha_entity_prefix IS NOT NULL AND ha_entity_prefix != ''" ).fetchall() conn.close() result = {} for p in printers: pfx = p['ha_entity_prefix'] def gs(suf, default='unknown', _p=pfx): return states.get(f'sensor.{_p}_{suf}', default) def gb(suf, _p=pfx): return states.get(f'binary_sensor.{_p}_{suf}', 'off') try: progress = int(float(gs('progression_de_l_impression', '0'))) except: progress = 0 try: layer = int(float(gs('couche_actuelle', '0'))) except: layer = 0 try: layers_total = int(float(gs('nombre_total_de_couches', '0'))) except: layers_total = 0 try: pieces_ignored = int(float(gs('objets_ignores', '0'))) except: pieces_ignored = 0 try: pieces_total = int(float(gs('objets_imprimables', '0'))) except: pieces_total = 0 result[str(p['id'])] = { 'printer_name': p['name'], 'status': gs('etat_de_l_impression'), 'progress': progress, 'task_name': gs('nom_de_la_tache'), 'heure_fin': gs('heure_de_fin'), 'layer': layer, 'layers_total': layers_total, 'error': gb('erreur_d_impression') == 'on', 'pieces_ignored': pieces_ignored, 'pieces_total': pieces_total, } return jsonify({'printers': result}) except Exception as e: return jsonify({'error': str(e), 'printers': {}}) # ─── Nextcloud WebDAV ──────────────────────────────────────────────────────── import xml.etree.ElementTree as ET import requests as _requests from urllib.parse import quote, unquote def _nc_session(): """Retourne (session_requests, base_url, username) ou None si non configuré.""" s = get_settings() url = str(s.get('nc_url', '')).rstrip('/') user = str(s.get('nc_username', '')) pwd = str(s.get('nc_app_password', '')) if not url or not user or not pwd: return None, None, None sess = _requests.Session() sess.auth = (user, pwd) sess.headers['OCS-APIRequest'] = 'true' return sess, url, user def _dav_url(base_url, username, path): """Construit l'URL WebDAV pour un chemin Nextcloud.""" clean = path.lstrip('/') return f"{base_url}/remote.php/dav/files/{quote(username)}/{quote(clean, safe='/')}" def _parse_propfind(xml_bytes, base_url, username, current_path): """Parse la réponse PROPFIND et retourne liste de dicts {name, path, is_dir, size, mtime}.""" ns = {'d': 'DAV:'} root = ET.fromstring(xml_bytes) items = [] dav_prefix = f"/remote.php/dav/files/{username}/" for resp in root.findall('d:response', ns): href = resp.findtext('d:href', '', ns) # Enlever le préfixe DAV pour obtenir le chemin relatif rel = unquote(href).replace(f"/remote.php/dav/files/{username}", '').rstrip('/') if not rel: rel = '/' # Ignorer l'entrée courante elle-même norm_current = ('/' + current_path.strip('/')).rstrip('/') if rel == norm_current or (rel + '/') == norm_current: continue propstat = resp.find('d:propstat/d:prop', ns) is_dir = propstat.find('d:resourcetype/d:collection', ns) is not None if propstat is not None else False size = int(propstat.findtext('d:getcontentlength', '0', ns) or 0) if propstat is not None else 0 mtime = propstat.findtext('d:getlastmodified', '', ns) if propstat is not None else '' name = rel.split('/')[-1] or rel # Filtrer : dossiers ou extensions 3D # Gérer .gcode.3mf avant le split simple name_lower = name.lower() if name_lower.endswith('.gcode.3mf'): ext = '3mf' elif '.' in name: ext = name.rsplit('.', 1)[-1].lower() else: ext = '' if not is_dir and ext not in ('3mf', 'stl', 'step', 'stp', 'obj', 'gcode'): continue items.append({'name': name, 'path': rel, 'is_dir': is_dir, 'size': size, 'mtime': mtime, 'ext': ext if not is_dir else 'dir'}) def _mtime_ts(m): try: from email.utils import parsedate_to_datetime return parsedate_to_datetime(m).timestamp() except Exception: return 0 items.sort(key=lambda x: (0 if x['is_dir'] else 1, -_mtime_ts(x['mtime']))) return items @app.route('/api/nextcloud/browse') def nc_browse(): path = request.args.get('path', '') sess, base_url, username = _nc_session() if not sess: return jsonify({'error': 'Nextcloud non configuré'}), 503 # Si path vide, partir du nc_root_path if not path: s = get_settings() path = str(s.get('nc_root_path', '/3D')) dav_url = _dav_url(base_url, username, path) try: r = sess.request('PROPFIND', dav_url, headers={'Depth': '1', 'Content-Type': 'application/xml'}, data='' '' '', timeout=10) if r.status_code == 404: return jsonify({'error': f'Chemin introuvable : {path}'}), 404 if r.status_code not in (207, 200): return jsonify({'error': f'Erreur WebDAV {r.status_code}'}), 502 items = _parse_propfind(r.content, base_url, username, path) # Construire le breadcrumb parts = [p for p in path.split('/') if p] breadcrumb = [{'name': 'Nextcloud', 'path': '/'}] for i, p in enumerate(parts): breadcrumb.append({'name': p, 'path': '/' + '/'.join(parts[:i+1])}) return jsonify({'path': path, 'items': items, 'breadcrumb': breadcrumb}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/nextcloud/link') def nc_link(): """Génère un lien de partage Nextcloud pour un fichier via l'API OCS.""" path = request.args.get('path', '') sess, base_url, username = _nc_session() if not sess or not path: return jsonify({'error': 'Paramètre manquant ou Nextcloud non configuré'}), 400 try: r = sess.post( f"{base_url}/ocs/v2.php/apps/files_sharing/api/v1/shares", data={'path': path, 'shareType': 3, 'permissions': 1}, # 3=public link, 1=read headers={'Accept': 'application/json'}, timeout=10) data = r.json() token = data.get('ocs', {}).get('data', {}).get('token', '') if token: return jsonify({'url': f"{base_url}/s/{token}"}) return jsonify({'error': 'Impossible de créer le lien'}), 500 except Exception as e: return jsonify({'error': str(e)}), 500 # ─── Portail client ─────────────────────────────────────────────────────────── @app.route('/jobs//generate-token', methods=['POST']) def generate_client_token(id): """Génère ou regénère le token de partage client pour une commande.""" token = secrets.token_urlsafe(20) conn = get_db() conn.execute('UPDATE jobs SET client_token=? WHERE id=?', (token, id)) conn.commit(); conn.close() return jsonify({'token': token, 'url': url_for('client_portal', token=token, _external=True)}) @app.route('/jobs//revoke-token', methods=['POST']) def revoke_client_token(id): conn = get_db() conn.execute('UPDATE jobs SET client_token=NULL WHERE id=?', (id,)) conn.commit(); conn.close() return jsonify({'ok': True}) @app.route('/portal/') def client_portal(token): """Vue publique (sans prix) pour le client.""" conn = get_db() job = conn.execute(''' SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type, m.color as material_color FROM jobs j LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id WHERE j.client_token=?''', (token,)).fetchone() if not job: conn.close() return render_template('portal_404.html'), 404 slots = conn.execute(''' SELECT ps.planned_start, ps.planned_end, ps.status, ps.failure_note, p.name as printer_name FROM print_slots ps JOIN printers p ON ps.printer_id=p.id WHERE ps.job_id=? ORDER BY ps.planned_start''', (job['id'],)).fetchall() 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.""" path = request.args.get('path', '') if not path: return Response('Paramètre path manquant', status=400) sess, base_url, username = _nc_session() if not sess: return Response('Nextcloud non configuré', status=503) dav_url = _dav_url(base_url, username, path) try: r = sess.get(dav_url, timeout=30, stream=True) if r.status_code != 200: return Response(f'Erreur WebDAV {r.status_code}', status=502) filename = path.split('/')[-1] ext = filename.rsplit('.', 1)[-1].lower() mime_map = { 'stl': 'model/stl', '3mf': 'model/3mf', 'step': 'application/step', 'stp': 'application/step', 'obj': 'model/obj', } mime = mime_map.get(ext, 'application/octet-stream') return Response(r.content, mimetype=mime, headers={'Content-Disposition': f'inline; filename="{filename}"', 'Access-Control-Allow-Origin': '*'}) except Exception as e: return Response(str(e), status=500) # ─── Paramètres ─────────────────────────────────────────────────────────────── @app.route('/settings', methods=['GET','POST']) def settings(): if request.method == 'POST': conn = get_db() for key in request.form: conn.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (key, request.form[key])) conn.commit(); conn.close() return redirect(url_for('settings')) return render_template('settings.html', settings=get_settings()) # ─── Statistiques ───────────────────────────────────────────────────────────── @app.route('/stats') def stats(): conn = get_db() by_client = conn.execute(''' SELECT COALESCE(c.name,'Sans client') as name, COUNT(j.id) as jobs, ROUND(SUM(j.final_price),2) as revenue, ROUND(AVG(j.gross_margin_pct),1) as avg_margin FROM jobs j LEFT JOIN clients c ON j.client_id=c.id GROUP BY j.client_id ORDER BY revenue DESC''').fetchall() by_month = conn.execute(''' SELECT strftime('%Y-%m',created_at) as month, COUNT(*) as jobs, ROUND(SUM(final_price),2) as revenue FROM jobs GROUP BY month ORDER BY month DESC LIMIT 12''').fetchall() by_material = conn.execute(''' SELECT COALESCE(m.name,'Non defini') as name, m.type, COUNT(j.id) as jobs, ROUND(SUM(j.weight_g),1) as total_g, ROUND(SUM(j.final_price),2) as revenue FROM jobs j LEFT JOIN materials m ON j.material_id=m.id GROUP BY j.material_id ORDER BY total_g DESC''').fetchall() waste_by_material = conn.execute(''' SELECT COALESCE(m.name,'Non défini') as name, m.type, COALESCE(m.color,'') as color, ROUND(COALESCE(m.total_wasted_g,0),1) as wasted_g, ROUND(COALESCE(m.stock_g,0),1) as stock_g FROM materials m WHERE COALESCE(m.total_wasted_g,0) > 0 ORDER BY wasted_g DESC''').fetchall() failure_by_printer = conn.execute(''' SELECT COALESCE(p.name,'Inconnu') as name, COUNT(ps.id) as total_slots, SUM(CASE WHEN ps.status='failed' THEN 1 ELSE 0 END) as failed_slots, ROUND(SUM(COALESCE(ps.wasted_g,0)),1) as wasted_g FROM print_slots ps LEFT JOIN printers p ON ps.printer_id=p.id GROUP BY ps.printer_id HAVING total_slots > 0 ORDER BY failed_slots DESC''').fetchall() conn.close() return render_template('stats.html', by_client=by_client, by_month=by_month, by_material=by_material, waste_by_material=waste_by_material, failure_by_printer=failure_by_printer) # ─── Export CSV ─────────────────────────────────────────────────────────────── @app.route('/export/csv') def export_csv(): conn = get_db() jobs = conn.execute(''' SELECT j.created_at, COALESCE(c.name,'') as client, COALESCE(m.name,'') as material, m.type as material_type, j.source_file, j.name, j.weight_g, j.print_time_s, j.pieces_per_plate, j.order_qty, j.design_multiplier, j.gross_margin_pct, j.discount_pct, j.cout_fixe, j.handling_cost, j.design_cost, j.total_marge, j.margin_amount, j.cotisations_amount, j.vfl_amount, j.other_taxes_amount, j.tva_amount, j.price_per_piece, j.final_price FROM jobs j LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id ORDER BY j.created_at DESC''').fetchall() conn.close() out = io.StringIO() w = csv.writer(out, delimiter=';') w.writerow(['Date','Client','Matiere','Type','Fichier source','Nom', 'Poids(g)','Temps(s)','Pièces/plateau','Qté commande', 'Coeff Design','Marge(%)','Remise(%)', 'Cout fixe','Manutention','Design','Total marge', 'Marge brute','Cotisations','VFL','Autres taxes','TVA', 'Prix/pièce','Prix Final']) for j in jobs: w.writerow(list(j)) return Response(out.getvalue(), mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=tarifs_3d.csv'}) if __name__ == '__main__': init_db() _start_ha_poll() app.run(host='0.0.0.0', port=5000, debug=False)