Files
3d-pricing/app.py
T

450 lines
21 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 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,
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,
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'),('tax_rate_pct','12.3'),('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):
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']
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)
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),
}
def fmt_time(s):
return f"{s//3600}h{(s%3600)//60:02d}m"
app.jinja_env.filters['fmt_time'] = fmt_time
@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)
@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'))
@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'))
@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()
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()
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,
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',''),
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','')))
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)
@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
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.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'))
@app.route('/api/calculate', methods=['POST'])
def api_calculate():
d = request.json
s = get_settings()
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']
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']),
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)
@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.commit(); conn.close()
return redirect(url_for('settings'))
return render_template('settings.html', settings=get_settings())
@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)
@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.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
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(%)',
'Matiere EUR','Marge Mat.','Design','Manutention','Usure',
'Electricite','Sous-total','Marge Brute','Cotisations','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)