From b25c3d9170ffaf97d54f1758b0d84aef4f9d7dc0 Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 6 Jul 2026 12:40:10 +0200 Subject: [PATCH] Templates, decomposition prix restructuree, TVA + VFL, fix DM buttons --- app.py | 458 ++++++++++++++++++++++++--- docker-compose.yml | 19 +- templates/base.html | 10 +- templates/handling_profile_form.html | 54 ++++ templates/job_detail.html | 88 +++-- templates/machine_profile_form.html | 115 +++++++ templates/material_profile_form.html | 48 +++ templates/new_job.html | 302 ++++++++++++++---- templates/pricing_profile_form.html | 73 +++++ templates/profiles.html | 238 ++++++++++++++ templates/settings.html | 58 +++- 11 files changed, 1311 insertions(+), 152 deletions(-) create mode 100644 templates/handling_profile_form.html create mode 100644 templates/machine_profile_form.html create mode 100644 templates/material_profile_form.html create mode 100644 templates/pricing_profile_form.html create mode 100644 templates/profiles.html diff --git a/app.py b/app.py index 6ef45b8..e950414 100644 --- a/app.py +++ b/app.py @@ -32,17 +32,61 @@ def init_db(): 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, - subtotal REAL, margin_amount REAL, tax_amount REAL, final_price 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')) ); ''') @@ -52,7 +96,11 @@ def init_db(): ('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'),('tax_rate_pct','12.3'),('printer_power_kw','0.35'), + ('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() @@ -110,9 +158,27 @@ 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): + """ + 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'] + + 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 @@ -122,30 +188,65 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, 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'] - subtotal = mat + mat_margin + design + handling + wear + elec - margin = subtotal * (gross_margin_pct / 100) - pre_tax = subtotal + margin - tax_rate = s['tax_rate_pct'] / 100 - with_tax = pre_tax / (1 - tax_rate) - tax_amt = with_tax - pre_tax - final = with_tax * (1 - discount_pct / 100) + + 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 { - 'material_cost': round(mat, 4), - 'material_margin': round(mat_margin, 4), - 'design_cost': round(design, 4), - 'handling_cost': round(handling, 4), - 'wear_cost': round(wear, 4), - 'electricity_cost': round(elec, 4), - 'subtotal': round(subtotal, 4), - 'margin_amount': round(margin, 4), - 'tax_amount': round(tax_amt, 4), - 'final_price': round(final, 2), + # 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() @@ -164,6 +265,8 @@ def dashboard(): conn.close() return render_template('dashboard.html', jobs=recent, stats=stats, low_stock=low_stock) +# ─── Clients ────────────────────────────────────────────────────────────────── + @app.route('/clients') def clients(): conn = get_db() @@ -206,6 +309,8 @@ def delete_client(id): conn.commit(); conn.close() return redirect(url_for('clients')) +# ─── Matières ───────────────────────────────────────────────────────────────── + @app.route('/materials') def materials(): conn = get_db() @@ -277,6 +382,188 @@ def delete_material(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() @@ -291,9 +578,14 @@ def 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() - settings = get_settings() + 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)) @@ -305,37 +597,80 @@ def new_job(): 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, settings) - conn.execute('''INSERT INTO jobs(client_id,material_id,source_file,name,description, + + 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,subtotal,margin_amount,tax_amount,final_price,notes) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', - (client_id,material_id,source_file,request.form['name'],request.form.get('description',''), + 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['subtotal'],r['margin_amount'], - r['tax_amount'],r['final_price'],request.form.get('notes',''))) + 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) + 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 - FROM jobs j LEFT JOIN clients c ON j.client_id=c.id + 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: @@ -349,22 +684,49 @@ def delete_job(id): conn.commit(); conn.close() return redirect(url_for('jobs')) +# ─── API ────────────────────────────────────────────────────────────────────── + @app.route('/api/calculate', methods=['POST']) def api_calculate(): d = request.json - s = get_settings() + 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'): - conn = get_db() mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(d['material_id'],)).fetchone() - conn.close() 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['design_multiplier']), - gross_margin_pct=float(d['gross_margin_pct']), + 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) @@ -389,16 +751,20 @@ def api_parse_3mf(): 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('UPDATE settings SET value=? WHERE key=?', (request.form[key], key)) + 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() @@ -419,6 +785,8 @@ def stats(): 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() @@ -427,9 +795,9 @@ def export_csv(): 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.material_cost, j.material_margin, j.design_cost, j.handling_cost, - j.wear_cost, j.electricity_cost, j.subtotal, j.margin_amount, - j.tax_amount, j.final_price + 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() @@ -437,8 +805,8 @@ def export_csv(): w = csv.writer(out, delimiter=';') w.writerow(['Date','Client','Matiere','Type','Fichier source','Nom', 'Poids(g)','Temps(s)','Coeff Design','Marge(%)','Remise(%)', - 'Matiere EUR','Marge Mat.','Design','Manutention','Usure', - 'Electricite','Sous-total','Marge Brute','Cotisations','Prix Final']) + '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', diff --git a/docker-compose.yml b/docker-compose.yml index a309d50..4598c0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,24 @@ services: build: . container_name: 3d-pricing restart: unless-stopped - ports: - - "5010:5000" # Accessible sur http://IP_DE_CETTE_VM:5010 volumes: + # Données persistantes (base SQLite) - ./data:/data environment: - DATABASE_PATH=/data/pricing.db + labels: + # --- Traefik --- + - "traefik.enable=true" + # Remplacez pricing.votre-domaine.com par votre domaine réel + - "traefik.http.routers.3d-pricing.rule=Host(`pricing.votre-domaine.com`)" + - "traefik.http.routers.3d-pricing.entrypoints=websecure" + - "traefik.http.routers.3d-pricing.tls=true" + - "traefik.http.routers.3d-pricing.tls.certresolver=letsencrypt" + - "traefik.http.services.3d-pricing.loadbalancer.server.port=5000" + networks: + - traefik_proxy + +networks: + traefik_proxy: + external: true + # Nom du réseau Traefik existant — ajustez si différent diff --git a/templates/base.html b/templates/base.html index c9a8692..ce436e7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -52,6 +52,8 @@ .breakdown-row { display: flex; justify-content: space-between; padding: .2rem 0; font-size: .88rem; } .breakdown-row.total { font-weight: 700; font-size: 1rem; border-top: 2px solid #dee2e6; padding-top: .5rem; margin-top: .3rem; } .breakdown-row.subtotal { border-top: 1px solid #dee2e6; padding-top: .4rem; margin-top: .2rem; } + .breakdown-row.section-header { font-weight: 600; font-size: .8rem; text-transform: uppercase; + letter-spacing: .04em; color: #6b7280; padding-top: .6rem; margin-top: .2rem; border-top: 1px dashed #e5e7eb; } .breakdown-muted { color: #6b7280; } .final-price { font-size: 2rem; font-weight: 700; color: #ff6b35; } @@ -82,6 +84,9 @@ Matières + + Templates + Statistiques @@ -107,4 +112,7 @@ {% block content %}{% endblock %} - +{% block scripts %}{% endblock %} + + diff --git a/templates/handling_profile_form.html b/templates/handling_profile_form.html new file mode 100644 index 0000000..d2a9c51 --- /dev/null +++ b/templates/handling_profile_form.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} +{% block title %}{% if profile %}Modifier{% else %}Nouveau{% endif %} profil manutention{% endblock %} + +{% block content %} + + +
+
+
+
+
Parametres
+
+
+ + +
+
+ + +
Votre cout de main d'oeuvre par heure.
+
+
+ + +
Preparation + retrait pieces + nettoyage plateau, par impression.
+
+
+ + +
+
+
+
+ + Annuler +
+
+
+
+{% endblock %} diff --git a/templates/job_detail.html b/templates/job_detail.html index 3f21660..0d73e91 100644 --- a/templates/job_detail.html +++ b/templates/job_detail.html @@ -22,7 +22,7 @@
- + - + - + + {% if job.machine_profile_name %} + + {% endif %} + {% if job.handling_profile_name %} + + {% endif %} + {% if job.pricing_profile_name %} + + {% endif %}
Client{{ job.client_name or '—' }}
Matière
Matiere {% if job.material_name %} {{ job.material_type }} {{ job.material_name }} @@ -30,13 +30,22 @@
Fichier source{{ job.source_file or '—' }}
{{ job.source_file or '—' }}
Date{{ job.created_at[:10] }}
Poids filament{{ job.weight_g }} g
Durée impression{{ job.print_time_s | fmt_time }}
Duree impression{{ job.print_time_s | fmt_time }}
Coeff design×{{ job.design_multiplier }}
Marge brute{{ job.gross_margin_pct }} %
Remise{{ job.discount_pct }} %
Profil machine{{ job.machine_profile_name }}
Profil manutention{{ job.handling_profile_name }}
Profil tarif{{ job.pricing_profile_name }}
{% if job.notes %}
@@ -48,30 +57,56 @@
-
Décomposition du prix
+
Decomposition du prix
-
Matière{{ "%.4f"|format(job.material_cost) }} €
-
Marge matière{{ "%.4f"|format(job.material_margin) }} €
-
Design (×{{ job.design_multiplier }}){{ "%.4f"|format(job.design_cost) }} €
-
Manutention{{ "%.4f"|format(job.handling_cost) }} €
-
Usure machine{{ "%.4f"|format(job.wear_cost) }} €
-
Électricité{{ "%.4f"|format(job.electricity_cost) }} €
+ + +
Cout fixe
+
Matiere{{ "%.4f"|format(job.material_cost) }} €
+
Marge matiere{{ "%.4f"|format(job.material_margin) }} €
+
Usure machine{{ "%.4f"|format(job.wear_cost) }} €
+
Electricite{{ "%.4f"|format(job.electricity_cost) }} €
- Sous-total coûts - {{ "%.2f"|format(job.subtotal) }} € + Total cout fixe + + {% if job.cout_fixe %}{{ "%.2f"|format(job.cout_fixe) }}{% else %}{{ "%.2f"|format(job.material_cost + job.material_margin + job.wear_cost + job.electricity_cost) }}{% endif %} € +
+ + +
Partie variable
+
Manutention{{ "%.4f"|format(job.handling_cost) }} €
+
Design (×{{ job.design_multiplier }}){{ "%.4f"|format(job.design_cost) }} €
+
Marge brute ({{ job.gross_margin_pct }}%){{ "%.2f"|format(job.margin_amount) }} €
+
+ Total marge + + {% if job.total_marge %}{{ "%.2f"|format(job.total_marge) }}{% else %}{{ "%.2f"|format(job.handling_cost + job.design_cost + job.margin_amount) }}{% endif %} € + +
+ + +
Fiscal
- Marge brute ({{ job.gross_margin_pct }}%) - {{ "%.2f"|format(job.margin_amount) }} € + Cotisations sociales + {% if job.cotisations_amount %}{{ "%.2f"|format(job.cotisations_amount) }}{% else %}{{ "%.2f"|format(job.tax_amount) }}{% endif %} €
+ {% if job.vfl_amount and job.vfl_amount > 0 %}
- Cotisations micro-ent. - {{ "%.2f"|format(job.tax_amount) }} € + VFL impot + {{ "%.2f"|format(job.vfl_amount) }} €
+ {% endif %} + {% if job.tva_amount and job.tva_amount > 0 %} +
+ TVA + {{ "%.2f"|format(job.tva_amount) }} € +
+ {% endif %} {% if job.discount_pct > 0 %}
- Remise ({{ job.discount_pct }}%) - −{{ "%.2f"|format((job.subtotal + job.margin_amount + job.tax_amount) * job.discount_pct / 100) }} € + Remise ({{ job.discount_pct }}%) + −{{ "%.2f"|format(job.final_price / (1 - job.discount_pct/100) * (job.discount_pct/100)) }} €
{% endif %}
@@ -80,18 +115,21 @@
- {% set total = job.final_price %} + {% set cout_f = job.cout_fixe or (job.material_cost + job.material_margin + job.wear_cost + job.electricity_cost) %} + {% set tot_m = job.total_marge or (job.handling_cost + job.design_cost + job.margin_amount) %} + {% set fiscal = (job.cotisations_amount or job.tax_amount or 0) + (job.vfl_amount or 0) + (job.tva_amount or 0) %} + {% set total = job.final_price %} {% if total > 0 %}
- Coûts ({{ (job.subtotal/total*100)|round(1) }}%) - Marge ({{ (job.margin_amount/total*100)|round(1) }}%) - Taxes ({{ (job.tax_amount/total*100)|round(1) }}%) + Cout fixe ({{ (cout_f/total*100)|round(1) }}%) + Marge ({{ (tot_m/total*100)|round(1) }}%) + Fiscal ({{ (fiscal/total*100)|round(1) }}%)
-
-
-
+
+
+
{% endif %} diff --git a/templates/machine_profile_form.html b/templates/machine_profile_form.html new file mode 100644 index 0000000..3f4b550 --- /dev/null +++ b/templates/machine_profile_form.html @@ -0,0 +1,115 @@ +{% extends 'base.html' %} +{% block title %}{% if profile %}Modifier{% else %}Nouveau{% endif %} profil machine{% endblock %} + +{% block content %} + + +
+
+
+
+
Identite
+
+
+ + +
+
+ + +
+
+
+ +
+
Imprimante
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
Buse (nozzle)
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
Plateau (plate)
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+
Electricite
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + Annuler +
+
+
+
+{% endblock %} diff --git a/templates/material_profile_form.html b/templates/material_profile_form.html new file mode 100644 index 0000000..b2024f0 --- /dev/null +++ b/templates/material_profile_form.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} +{% block title %}{% if profile %}Modifier{% else %}Nouveau{% endif %} profil matiere{% endblock %} + +{% block content %} + + +
+
+
+
+
Parametres
+
+
+ + +
+
+ + +
Applique sur le cout matiere brut (prix/kg x poids).
+
+
+ + +
+
+
+
+ + Annuler +
+
+
+
+{% endblock %} diff --git a/templates/new_job.html b/templates/new_job.html index 6960436..a129c54 100644 --- a/templates/new_job.html +++ b/templates/new_job.html @@ -8,6 +8,7 @@
+
@@ -22,22 +23,26 @@
-
Glissez votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.
+
Votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.
+ +
Donnees impression
- +
- +
@@ -51,12 +56,13 @@
- + +
Champ Modele dans Bambu
@@ -83,6 +90,71 @@
+ +
+
+ Profils + + Gerer + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
Parametres tarifaires
@@ -90,22 +162,31 @@ -
- - +
+ +
- +
- -
20%35%
+ +
+ 20%35% +
- +
@@ -120,9 +201,12 @@
+
-
Recapitulatif temps reel
+
+ Recapitulatif temps reel +
@@ -136,6 +220,7 @@ {% block scripts %} {% endblock %} diff --git a/templates/pricing_profile_form.html b/templates/pricing_profile_form.html new file mode 100644 index 0000000..9a5f741 --- /dev/null +++ b/templates/pricing_profile_form.html @@ -0,0 +1,73 @@ +{% extends 'base.html' %} +{% block title %}{% if profile %}Modifier{% else %}Nouveau{% endif %} profil tarif{% endblock %} + +{% block content %} + + +
+
+
+
+
Parametres
+
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
20%35%
+
+
+ + +
+
+
+
+ + Annuler +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/profiles.html b/templates/profiles.html new file mode 100644 index 0000000..39e2e83 --- /dev/null +++ b/templates/profiles.html @@ -0,0 +1,238 @@ +{% extends 'base.html' %} +{% block title %}Templates / Profils{% endblock %} + +{% block content %} + + + + +
+ + +
+
+

Imprimante, buse, plateau et electricite. Remplace les parametres globaux lors du calcul.

+ + Nouveau + +
+ {% if machine_profiles %} +
+
+ + + + + + + + + + + + + + {% for p in machine_profiles %} + + + + + + + + + + {% endfor %} + +
NomImprimanteBusePlateauPuissanceElectricite
{{ p.name }}{{ p.printer_price }}€ / {{ p.printer_lifespan_hours|int }}h{{ p.nozzle_price }}€ / {{ p.nozzle_lifespan_hours|int }}h{{ p.plate_price }}€ / {{ p.plate_lifespan_hours|int }}h{{ p.printer_power_kw }} kW{{ p.electricity_price_kwh }} €/kWh + + + +
+ +
+
+
+
+ {% else %} +
+

Aucun profil machine.

+
+ {% endif %} +
+ + +
+
+

Taux horaire et temps de preparation par plateau.

+ + Nouveau + +
+ {% if handling_profiles %} +
+
+ + + + + + {% for p in handling_profiles %} + {% set cost = (p.handling_rate_per_hour / 60) * p.handling_minutes_per_plate %} + + + + + + + + {% endfor %} + +
NomTaux horaireMinutes / plateauCout fixe par job
{{ p.name }}{{ p.handling_rate_per_hour }} €/h{{ p.handling_minutes_per_plate }} min{{ "%.2f"|format(cost) }} € + + + +
+ +
+
+
+
+ {% else %} +
+

Aucun profil manutention.

+
+ {% endif %} +
+ + +
+
+

Marge appliquee sur le cout matiere. Utile pour differencier les types de filament (standard, technique, flexible...).

+ + Nouveau + +
+ {% if material_profiles %} +
+
+ + + + + + {% for p in material_profiles %} + + + + + + + {% endfor %} + +
NomMarge matiereNotes
{{ p.name }}{{ p.material_margin_pct }} %{{ p.notes or '' }} + + + +
+ +
+
+
+
+ {% else %} +
+

Aucun profil matiere.

+
+ {% endif %} +
+ + +
+
+

Coefficient design et marge brute. Creer un profil par type de relation client.

+ + Nouveau + +
+ {% if pricing_profiles %} +
+
+ + + + + + {% for p in pricing_profiles %} + + + + + + + + {% endfor %} + +
NomCoeff designMarge bruteNotes
{{ p.name }}x{{ p.design_multiplier }}{{ p.gross_margin_pct }} %{{ p.notes or '' }} + + + +
+ +
+
+
+
+ {% else %} +
+

Aucun profil tarif client.

+
+ {% endif %} +
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index 0142266..6883370 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1,9 +1,10 @@ {% extends 'base.html' %} -{% block title %}Paramètres{% endblock %} +{% block title %}Parametres{% endblock %} {% block content %}
@@ -19,10 +20,10 @@ step="0.01" value="{{ settings.filament_price_per_kg }}">
- + -
Appliquée sur le coût matière brut.
+
Appliquee sur le cout matiere brut.
@@ -30,7 +31,7 @@
-
Électricité
+
Electricite
@@ -58,7 +59,7 @@ step="1" value="{{ settings.printer_price }}">
- +
@@ -70,7 +71,7 @@ step="0.5" value="{{ settings.nozzle_price }}">
- +
@@ -82,7 +83,7 @@ step="0.5" value="{{ settings.plate_price }}">
- +
@@ -112,10 +113,41 @@
Fiscal
- - -
12.3% pour vente de marchandises (2024).
+ + +
12.3% pour prestation de services (2024). Calculees sur le CA HT.
+
+
+ + +
+ 1.7% pour prestation de services (option VFL). Mettre 0 si vous n'etes pas au VFL + (impot calcule separement sur declaration classique). +
+
+
+
+ + +
+ 0 si vous etes en franchise en base de TVA (CA < seuil). + 20 si vous avez depasse le seuil et devez facturer la TVA. + La TVA est affichee separement et s'ajoute au prix HT. +
+
+ {% set total_charges = settings.cotisations_rate_pct + settings.vfl_rate_pct %} +
+ + Charges totales sur CA : {{ total_charges }} % + (cotisations {{ settings.cotisations_rate_pct }}% + VFL {{ settings.vfl_rate_pct }}%) + {% if settings.tva_rate_pct > 0 %} + + TVA {{ settings.tva_rate_pct }}% ajoutee au prix HT + {% else %} + — franchise TVA active + {% endif %}
@@ -125,7 +157,7 @@