Files
3d-pricing/app.py
T

818 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>')
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/<int:id>/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/<int:id>')
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)