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