from flask import Flask, render_template, request, redirect, url_for, jsonify, Response import sqlite3, csv, io, os, zipfile, re app = Flask(__name__) DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db') def get_db(): conn = sqlite3.connect(DATABASE) conn.row_factory = sqlite3.Row 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 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 '', 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, 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, tax_amount REAL, final_price REAL, notes 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.7'), ('tva_rate_pct','0.0'), ('printer_power_kw','0.35'), ] conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults) conn.commit(); conn.close() def get_settings(): conn = get_db() rows = conn.execute('SELECT key,value FROM settings').fetchall() conn.close() return {r['key']: float(r['value']) for r in rows} def parse_3mf(file_stream): result = {'weight_g': None, 'hours': None, 'minutes': None, 'filename': None} try: with zipfile.ZipFile(file_stream) as z: for name in z.namelist(): 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'(\d+[\.,]\d+)\s*g\b(?!\s*[/])', r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)', r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)', ]: 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 return result def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, price_per_kg_override=None, s=None): """ Decomposition: Cout fixe = matiere + marge_matiere + usure + electricite Manutention = handling (fixe par plateau) Design = matiere × coeff_design Sous-total = cout_fixe + handling + design Marge brute = sous-total × gross_margin_pct Total marge = handling + design + marge_brute Prix HT base = cout_fixe + total_marge (= sous-total + marge) Charges (sur CA HT) : cotisations = prix_ht / (1 - cot - vfl) × cot vfl = prix_ht / (1 - cot - vfl) × vfl Prix HT net = prix_ht / (1 - cot - vfl) TVA = prix_ht_net × tva_rate Prix TTC = prix_ht_net × (1 + tva_rate) Prix final = prix_ttc × (1 - discount) """ if s is None: s = get_settings() price_kg = price_per_kg_override if price_per_kg_override is not None else s['filament_price_per_kg'] mat = (price_kg / 1000) * weight_g mat_margin = mat * (s['material_margin_pct'] / 100) design = mat * design_mult handling = (s['handling_rate_per_hour'] / 60) * s['handling_minutes_per_plate'] printer_w = (s['printer_price'] / (s['printer_lifespan_hours'] * 3600)) * print_time_s nozzle_w = (s['nozzle_price'] / (s['nozzle_lifespan_hours'] * 3600)) * print_time_s plate_w = (s['plate_price'] / (s['plate_lifespan_hours'] * 3600)) * print_time_s wear = printer_w + nozzle_w + plate_w elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh'] 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 # = sous_total + margin 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 total_charges = cot_rate + vfl_rate # Charges calculées sur le CA (prix de vente HT net) # prix_ht_net = prix_ht / (1 - total_charges) so that charges = prix_ht_net * total_charges 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 tva_amount = prix_ht_net * tva_rate prix_ttc = prix_ht_net + tva_amount final = prix_ttc * (1 - discount_pct / 100) return { # Lignes détaillées 'material_cost': round(mat, 4), 'material_margin': round(mat_margin, 4), 'wear_cost': round(wear, 4), 'electricity_cost': round(elec, 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), 'prix_ht': round(prix_ht, 4), # Fiscal 'cotisations_amount': round(cot_amount, 4), 'vfl_amount': round(vfl_amount, 4), 'tva_amount': round(tva_amount, 4), 'prix_ht_net': round(prix_ht_net, 4), 'prix_ttc': round(prix_ttc, 4), # Compat 'tax_amount': round(cot_amount + vfl_amount, 4), 'final_price': round(final, 2), # Taux appliqués (pour affichage) '_cot_pct': round(cot_rate * 100, 2), '_vfl_pct': round(vfl_rate * 100, 2), '_tva_pct': round(tva_rate * 100, 2), } def fmt_time(s): return f"{s//3600}h{(s%3600)//60:02d}m" app.jinja_env.filters['fmt_time'] = fmt_time # ─── 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() conn.close() return render_template('dashboard.html', jobs=recent, stats=stats, low_stock=low_stock) # ─── 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) @app.route('/clients/new', methods=['GET','POST']) def new_client(): if request.method == 'POST': conn = get_db() conn.execute('INSERT INTO clients(name,email,design_multiplier,notes) VALUES(?,?,?,?)', (request.form['name'], request.form.get('email',''), float(request.form['design_multiplier']), request.form.get('notes',''))) conn.commit(); conn.close() return redirect(url_for('clients')) return render_template('client_form.html', client=None) @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': conn.execute('UPDATE clients SET name=?,email=?,design_multiplier=?,notes=? WHERE id=?', (request.form['name'], request.form.get('email',''), float(request.form['design_multiplier']), request.form.get('notes',''), id)) conn.commit(); conn.close() return redirect(url_for('clients')) conn.close() return render_template('client_form.html', client=client) @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')) # ─── 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() conn.close() return render_template('materials.html', materials=mats) @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//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 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() 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() settings = get_settings() 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 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) conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id, material_profile_id,pricing_profile_id,source_file,name,description, weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct, material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost, cout_fixe,total_marge,subtotal,margin_amount, cotisations_amount,vfl_amount,tva_amount,tax_amount,final_price,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid, source_file,request.form['name'],request.form.get('description',''), weight_g,print_time_s,design_mult,gross_margin,discount, 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['cotisations_amount'],r['vfl_amount'],r['tva_amount'],r['tax_amount'], r['final_price'],request.form.get('notes',''))) if material_id: conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?', (weight_g, material_id)) conn.commit(); conn.close() 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) @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() conn.close() if not job: return redirect(url_for('jobs')) return render_template('job_detail.html', job=job) @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')) # ─── 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() result = calc( weight_g=float(d['weight_g']), print_time_s=int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60, 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) 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) result['filename'] = os.path.splitext(f.filename)[0] return jsonify(result) # ─── 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() conn.close() return render_template('stats.html', by_client=by_client, by_month=by_month, by_material=by_material) # ─── 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.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.tva_amount, 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)','Coeff Design','Marge(%)','Remise(%)', 'Cout fixe','Manutention','Design','Total marge', 'Marge brute','Cotisations','VFL','TVA','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() app.run(host='0.0.0.0', port=5000, debug=False)