855 lines
41 KiB
Python
855 lines
41 KiB
Python
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.0'),
|
||
('tva_rate_pct','20.0'),
|
||
('other_taxes_rate_pct','1.0'),
|
||
('printer_power_kw','0.35'),
|
||
]
|
||
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
||
|
||
# Migrations : ajout de colonnes si elles n'existent pas (upgrade DB existante)
|
||
migrations = [
|
||
'ALTER TABLE jobs ADD COLUMN machine_profile_id INTEGER',
|
||
'ALTER TABLE jobs ADD COLUMN handling_profile_id INTEGER',
|
||
'ALTER TABLE jobs ADD COLUMN material_profile_id INTEGER',
|
||
'ALTER TABLE jobs ADD COLUMN pricing_profile_id INTEGER',
|
||
'ALTER TABLE jobs ADD COLUMN cout_fixe REAL',
|
||
'ALTER TABLE jobs ADD COLUMN total_marge REAL',
|
||
'ALTER TABLE jobs ADD COLUMN cotisations_amount REAL',
|
||
'ALTER TABLE jobs ADD COLUMN vfl_amount REAL',
|
||
'ALTER TABLE jobs ADD COLUMN tva_amount REAL',
|
||
'ALTER TABLE jobs ADD COLUMN other_taxes_amount REAL',
|
||
]
|
||
for sql in migrations:
|
||
try:
|
||
conn.execute(sql)
|
||
except Exception:
|
||
pass # colonne deja existante
|
||
|
||
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
|
||
other_taxes_rate = s.get('other_taxes_rate_pct', 1.0) / 100
|
||
total_charges = cot_rate + vfl_rate + other_taxes_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
|
||
other_taxes_amount = prix_ht_net * other_taxes_rate
|
||
tva_amount = prix_ht_net * tva_rate
|
||
prix_ttc = prix_ht_net + tva_amount
|
||
final = prix_ttc * (1 - discount_pct / 100)
|
||
|
||
# % de marge effective sur le prix HT final
|
||
marge_pct_on_ht = (total_marge / prix_ht_net * 100) if prix_ht_net > 0 else 0
|
||
|
||
return {
|
||
# Lignes détaillées
|
||
'material_cost': round(mat, 4),
|
||
'material_margin': round(mat_margin, 4),
|
||
'wear_cost': round(wear, 4),
|
||
'printer_wear': round(printer_w, 4),
|
||
'nozzle_wear': round(nozzle_w, 4),
|
||
'plate_wear': round(plate_w, 4),
|
||
'electricity_cost': round(elec, 4),
|
||
'kwh_used': round(s['printer_power_kw'] * (print_time_s / 3600), 4),
|
||
'cout_fixe': round(cout_fixe, 4),
|
||
'handling_cost': round(handling, 4),
|
||
'design_cost': round(design, 4),
|
||
'subtotal': round(sous_total, 4),
|
||
'margin_amount': round(margin, 4),
|
||
'total_marge': round(total_marge, 4),
|
||
'marge_pct_on_ht': round(marge_pct_on_ht, 1),
|
||
'prix_ht': round(prix_ht, 4),
|
||
# Fiscal
|
||
'cotisations_amount': round(cot_amount, 4),
|
||
'vfl_amount': round(vfl_amount, 4),
|
||
'other_taxes_amount': round(other_taxes_amount, 4),
|
||
'tva_amount': round(tva_amount, 4),
|
||
'prix_ht_net': round(prix_ht_net, 4),
|
||
'prix_ttc': round(prix_ttc, 4),
|
||
# Compat
|
||
'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4),
|
||
'final_price': round(final, 2),
|
||
# Taux et paramètres 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),
|
||
'_other_taxes_pct': round(other_taxes_rate * 100, 2),
|
||
'_material_margin_pct': s['material_margin_pct'],
|
||
'_handling_rate': s['handling_rate_per_hour'],
|
||
'_handling_minutes': s['handling_minutes_per_plate'],
|
||
'_elec_price': s['electricity_price_kwh'],
|
||
}
|
||
|
||
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.get('design_multiplier', 1.80)), 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.get('design_multiplier', client['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)
|