Initial commit — 3D Pricing app
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Crée le dossier de données
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Lance l'init DB et le serveur
|
||||
CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && python app.py"]
|
||||
|
||||
EXPOSE 5000
|
||||
Binary file not shown.
@@ -0,0 +1,449 @@
|
||||
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)
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
3d-pricing:
|
||||
build: .
|
||||
container_name: 3d-pricing
|
||||
restart: unless-stopped
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
flask>=3.0.0
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}3D Pricing{% endblock %} — 3D Pricing</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
:root { --sidebar-w: 220px; }
|
||||
body { background: #f4f6f9; }
|
||||
|
||||
#sidebar {
|
||||
width: var(--sidebar-w); min-height: 100vh;
|
||||
background: #1a1d23; position: fixed; top: 0; left: 0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#sidebar .brand {
|
||||
padding: 1.25rem 1.25rem 1rem; color: #fff;
|
||||
font-weight: 700; font-size: 1.1rem;
|
||||
border-bottom: 1px solid #2e323d;
|
||||
}
|
||||
#sidebar .brand span { color: #ff6b35; }
|
||||
#sidebar nav { flex: 1; padding: .5rem 0; }
|
||||
#sidebar nav a {
|
||||
display: flex; align-items: center; gap: .65rem;
|
||||
padding: .6rem 1.25rem; color: #9ba3af;
|
||||
text-decoration: none; font-size: .875rem;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
#sidebar nav a:hover, #sidebar nav a.active { background: #2e323d; color: #fff; }
|
||||
#sidebar nav a.active { border-left: 3px solid #ff6b35; }
|
||||
#sidebar nav .nav-section {
|
||||
padding: .75rem 1.25rem .25rem; font-size: .7rem;
|
||||
text-transform: uppercase; letter-spacing: .08em; color: #4b5563;
|
||||
}
|
||||
|
||||
#main { margin-left: var(--sidebar-w); padding: 1.75rem 2rem; }
|
||||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-header h1 { font-size: 1.4rem; font-weight: 700; margin: 0; }
|
||||
|
||||
.stat-card {
|
||||
background: #fff; border-radius: 10px; padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
}
|
||||
.stat-card .label { font-size: .75rem; color: #6b7280; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.stat-card .value { font-size: 1.75rem; font-weight: 700; margin-top: .1rem; }
|
||||
|
||||
.card { border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.card-header { background: #fff; border-bottom: 1px solid #f0f0f0; font-weight: 600; }
|
||||
|
||||
.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-muted { color: #6b7280; }
|
||||
|
||||
.final-price { font-size: 2rem; font-weight: 700; color: #ff6b35; }
|
||||
|
||||
.stock-ok { color: #22c55e; }
|
||||
.stock-low { color: #f59e0b; }
|
||||
.stock-empty { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sidebar">
|
||||
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
|
||||
<nav>
|
||||
<div class="nav-section">Navigation</div>
|
||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('jobs') }}" class="{% if request.endpoint in ['jobs','job_detail'] %}active{% endif %}">
|
||||
<i class="bi bi-printer"></i> Commandes
|
||||
</a>
|
||||
<a href="{{ url_for('new_job') }}" class="{% if request.endpoint=='new_job' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> Nouveau calcul
|
||||
</a>
|
||||
<a href="{{ url_for('clients') }}" class="{% if 'client' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-building"></i> Clients
|
||||
</a>
|
||||
<a href="{{ url_for('materials') }}" class="{% if 'material' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-boxes"></i> Matières
|
||||
</a>
|
||||
<div class="nav-section">Analyse</div>
|
||||
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}">
|
||||
<i class="bi bi-bar-chart-line"></i> Statistiques
|
||||
</a>
|
||||
<a href="{{ url_for('export_csv') }}">
|
||||
<i class="bi bi-download"></i> Export CSV
|
||||
</a>
|
||||
<div class="nav-section">Config</div>
|
||||
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
|
||||
<i class="bi bi-sliders"></i> Paramètres
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} alert-dismissible fade show">
|
||||
{{ msg }} <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if client %}Modifier{% else %}Nouveau{% endif %} client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<a href="{{ url_for('clients') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left"></i> Clients
|
||||
</a>
|
||||
<h1 class="mt-1">{% if client %}Modifier {{ client.name }}{% else %}Nouveau client{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nom *</label>
|
||||
<input type="text" name="name" class="form-control" required
|
||||
value="{{ client.name if client else '' }}" placeholder="ex: Blockcorp">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" name="email" class="form-control"
|
||||
value="{{ client.email if client else '' }}">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Coefficient design par défaut</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="setDM(0.80)">
|
||||
×0.80 — STL fourni
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="setDM(1.80)">
|
||||
×1.80 — Création complète
|
||||
</button>
|
||||
</div>
|
||||
<input type="number" name="design_multiplier" id="dm" class="form-control"
|
||||
step="0.05" min="0" value="{{ client.design_multiplier if client else '1.80' }}">
|
||||
<div class="form-text">0.80 = client qui fournit ses STL. 1.80 = vous faites tout le design.</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3">{{ client.notes if client else '' }}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-1"></i>Enregistrer
|
||||
</button>
|
||||
<a href="{{ url_for('clients') }}" class="btn btn-outline-secondary">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function setDM(val) {
|
||||
document.getElementById('dm').value = val;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Clients{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-building me-2 text-info"></i>Clients</h1>
|
||||
<a href="{{ url_for('new_client') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Nouveau client
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if clients %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nom</th><th>Email</th><th>Coeff design par défaut</th>
|
||||
<th>Commandes</th><th>CA total</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ c.name }}</td>
|
||||
<td class="text-muted">{{ c.email or '—' }}</td>
|
||||
<td>
|
||||
{% if c.design_multiplier <= 0.80 %}
|
||||
<span class="badge bg-success">×{{ c.design_multiplier }} (STL fourni)</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">×{{ c.design_multiplier }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c.job_count }}</td>
|
||||
<td class="fw-semibold">{{ c.total_revenue or 0 }} €</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<a href="{{ url_for('edit_client', id=c.id) }}" class="btn btn-sm btn-outline-secondary py-0">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('delete_client', id=c.id) }}"
|
||||
onsubmit="return confirm('Supprimer ce client ? Ses commandes seront conservées.')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-building fs-1"></i>
|
||||
<p class="mt-2">Aucun client enregistré.</p>
|
||||
<a href="{{ url_for('new_client') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Ajouter le premier client</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Binary file not shown.
@@ -0,0 +1,102 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ job.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<a href="{{ url_for('jobs') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left"></i> Commandes
|
||||
</a>
|
||||
<h1 class="mt-1">{{ job.name }}</h1>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('delete_job', id=job.id) }}"
|
||||
onsubmit="return confirm('Supprimer cette commande ?')">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-5">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-3">Informations</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Matière</th>
|
||||
<td>
|
||||
{% if job.material_name %}
|
||||
<span class="badge bg-secondary">{{ job.material_type }}</span> {{ job.material_name }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th class="text-muted fw-normal">Fichier source</th>
|
||||
<td class="text-monospace small">{{ job.source_file or '—' }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Poids filament</th><td>{{ job.weight_g }} g</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Durée impression</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Coeff design</th><td>×{{ job.design_multiplier }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Marge brute</th><td>{{ job.gross_margin_pct }} %</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Remise</th><td>{{ job.discount_pct }} %</td></tr>
|
||||
</table>
|
||||
{% if job.notes %}
|
||||
<hr>
|
||||
<small class="text-muted">{{ job.notes }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-receipt me-2"></i>Décomposition du prix</div>
|
||||
<div class="card-body">
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Matière</span><span>{{ "%.4f"|format(job.material_cost) }} €</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Marge matière</span><span>{{ "%.4f"|format(job.material_margin) }} €</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Design (×{{ job.design_multiplier }})</span><span>{{ "%.4f"|format(job.design_cost) }} €</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Manutention</span><span>{{ "%.4f"|format(job.handling_cost) }} €</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Usure machine</span><span>{{ "%.4f"|format(job.wear_cost) }} €</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Électricité</span><span>{{ "%.4f"|format(job.electricity_cost) }} €</span></div>
|
||||
<div class="breakdown-row subtotal">
|
||||
<span class="fw-semibold">Sous-total coûts</span>
|
||||
<span class="fw-semibold">{{ "%.2f"|format(job.subtotal) }} €</span>
|
||||
</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-muted">Marge brute ({{ job.gross_margin_pct }}%)</span>
|
||||
<span>{{ "%.2f"|format(job.margin_amount) }} €</span>
|
||||
</div>
|
||||
<div class="breakdown-row">
|
||||
<span class="breakdown-muted">Cotisations micro-ent.</span>
|
||||
<span>{{ "%.2f"|format(job.tax_amount) }} €</span>
|
||||
</div>
|
||||
{% if job.discount_pct > 0 %}
|
||||
<div class="breakdown-row text-danger">
|
||||
<span>Remise ({{ job.discount_pct }}%)</span>
|
||||
<span>−{{ "%.2f"|format((job.subtotal + job.margin_amount + job.tax_amount) * job.discount_pct / 100) }} €</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="breakdown-row total">
|
||||
<span>PRIX FINAL</span>
|
||||
<span class="final-price">{{ "%.2f"|format(job.final_price) }} €</span>
|
||||
</div>
|
||||
|
||||
<!-- Barre de composition -->
|
||||
{% set total = job.final_price %}
|
||||
{% if total > 0 %}
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between small text-muted mb-1">
|
||||
<span>Coûts ({{ (job.subtotal/total*100)|round(1) }}%)</span>
|
||||
<span>Marge ({{ (job.margin_amount/total*100)|round(1) }}%)</span>
|
||||
<span>Taxes ({{ (job.tax_amount/total*100)|round(1) }}%)</span>
|
||||
</div>
|
||||
<div class="progress" style="height:18px;border-radius:6px">
|
||||
<div class="progress-bar bg-secondary" style="width:{{ (job.subtotal/total*100)|round(1) }}%"></div>
|
||||
<div class="progress-bar" style="width:{{ (job.margin_amount/total*100)|round(1) }}%;background:#ff6b35"></div>
|
||||
<div class="progress-bar bg-warning text-dark" style="width:{{ (job.tax_amount/total*100)|round(1) }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Commandes{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-printer me-2 text-primary"></i>Commandes</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('export_csv') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download me-1"></i>Export CSV
|
||||
</a>
|
||||
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Nouveau
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if jobs %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th><th>Nom</th><th>Client</th><th>Poids</th>
|
||||
<th>Durée</th><th>Design</th><th>Marge</th><th>Remise</th>
|
||||
<th class="text-end">Prix final</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ j.created_at[:10] }}</td>
|
||||
<td><a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a></td>
|
||||
<td>{{ j.client_name or '—' }}</td>
|
||||
<td>{{ j.weight_g }} g</td>
|
||||
<td>{{ j.print_time_s | fmt_time }}</td>
|
||||
<td><span class="badge bg-light text-dark">×{{ j.design_multiplier }}</span></td>
|
||||
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</span></td>
|
||||
<td>
|
||||
{% if j.discount_pct > 0 %}
|
||||
<span class="badge bg-danger">-{{ j.discount_pct }}%</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }} €</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0">→</a>
|
||||
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
|
||||
onsubmit="return confirm('Supprimer ?')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-1"></i>
|
||||
<p class="mt-2">Aucune commande enregistrée.</p>
|
||||
<a href="{{ url_for('new_job') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">Créer le premier calcul</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if material %}Modifier{% else %}Nouvelle{% endif %} matière{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<a href="{{ url_for('materials') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left"></i> Matières
|
||||
</a>
|
||||
<h1 class="mt-1">{% if material %}Modifier — {{ material.name }}{% else %}Nouvelle matière{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="row g-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">Nom *</label>
|
||||
<input type="text" name="name" class="form-control" required
|
||||
value="{{ material.name if material else '' }}"
|
||||
placeholder="ex: PLA+ Rouge Bambu">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">Type</label>
|
||||
<select name="type" class="form-select">
|
||||
{% for t in ['PLA','PLA+','PETG','ASA','ABS','TPU','Nylon','PC','Résine','Autre'] %}
|
||||
<option value="{{ t }}" {% if material and material.type==t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Marque</label>
|
||||
<input type="text" name="brand" class="form-control"
|
||||
value="{{ material.brand if material else '' }}"
|
||||
placeholder="Bambu, eSUN, Polymaker…">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Couleur</label>
|
||||
<input type="text" name="color" class="form-control"
|
||||
value="{{ material.color if material else '' }}"
|
||||
placeholder="Rouge, #FF0000…">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Prix (€/kg) *</label>
|
||||
<input type="number" name="price_per_kg" class="form-control"
|
||||
step="0.01" min="0" required
|
||||
value="{{ material.price_per_kg if material else '25.00' }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Stock actuel (g)</label>
|
||||
<input type="number" name="stock_g" class="form-control"
|
||||
step="1" min="0"
|
||||
value="{{ material.stock_g|round(0)|int if material else 0 }}">
|
||||
<div class="form-text">1 rouleau ≈ 1000 g</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Seuil alerte stock bas (g)</label>
|
||||
<input type="number" name="low_stock_threshold_g" class="form-control"
|
||||
step="50" min="0"
|
||||
value="{{ material.low_stock_threshold_g|round(0)|int if material else 200 }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ material.notes if material else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-1"></i>Enregistrer
|
||||
</button>
|
||||
<a href="{{ url_for('materials') }}" class="btn btn-outline-secondary">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if history %}
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-clock-history me-2"></i>Historique des prix</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light"><tr><th>Date</th><th class="text-end">€/kg</th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in history %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ h.recorded_at[:10] }}</td>
|
||||
<td class="text-end fw-semibold">{{ h.price_per_kg }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Matières{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-boxes me-2" style="color:#8b5cf6"></i>Matières & Stock</h1>
|
||||
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ajouter une matière
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if materials %}
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nom</th><th>Type</th><th>Couleur</th>
|
||||
<th>Prix/kg</th><th>Stock</th><th>Commandes</th><th>Utilisé total</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in materials %}
|
||||
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ m.name }}{% if m.brand %} <small class="text-muted">/ {{ m.brand }}</small>{% endif %}</td>
|
||||
<td><span class="badge bg-secondary">{{ m.type }}</span></td>
|
||||
<td>
|
||||
{% if m.color %}
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
<span style="width:14px;height:14px;border-radius:50%;background:{{ m.color }};border:1px solid #ccc;display:inline-block"></span>
|
||||
{{ m.color }}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="fw-semibold">{{ m.price_per_kg }} €</td>
|
||||
<td style="min-width:140px">
|
||||
{% if m.stock_g <= 0 %}
|
||||
<span class="stock-empty fw-bold"><i class="bi bi-x-circle"></i> Rupture</span>
|
||||
{% elif m.stock_g <= m.low_stock_threshold_g %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="stock-low"><i class="bi bi-exclamation-triangle"></i> {{ m.stock_g|round(0)|int }} g</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="stock-ok"><i class="bi bi-check-circle"></i> {{ m.stock_g|round(0)|int }} g</span>
|
||||
{% endif %}
|
||||
<div class="progress mt-1" style="height:4px">
|
||||
<div class="progress-bar
|
||||
{% if m.stock_g <= 0 %}bg-danger
|
||||
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
|
||||
{% else %}bg-success{% endif %}"
|
||||
style="width:{{ [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min }}%">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ m.job_count }}</td>
|
||||
<td>{{ m.total_used_g }} g</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
|
||||
data-bs-target="#restockModal{{ m.id }}">
|
||||
<i class="bi bi-plus-lg"></i> Stock
|
||||
</button>
|
||||
<a href="{{ url_for('edit_material', id=m.id) }}" class="btn btn-sm btn-outline-secondary py-0">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('delete_material', id=m.id) }}"
|
||||
onsubmit="return confirm('Supprimer cette matière ?')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Modal Restock -->
|
||||
<div class="modal fade" id="restockModal{{ m.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('restock_material', id=m.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Réapprovisionner — {{ m.name }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Quantité à ajouter (g)</label>
|
||||
<input type="number" name="add_g" class="form-control" step="1" min="1"
|
||||
placeholder="ex: 1000 pour 1 kg" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nouveau prix/kg (optionnel)</label>
|
||||
<input type="number" name="new_price_per_kg" class="form-control"
|
||||
step="0.01" placeholder="{{ m.price_per_kg }} (actuel)">
|
||||
<div class="form-text">Laissez vide si le prix n'a pas changé.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="submit" class="btn btn-success btn-sm">Ajouter au stock</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-boxes fs-1"></i>
|
||||
<p class="mt-2">Aucune matière enregistrée.</p>
|
||||
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
|
||||
Ajouter votre premier filament
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,233 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Nouveau calcul{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-plus-circle me-2" style="color:#ff6b35"></i>Nouveau calcul de prix</h1>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<!-- Import 3mf -->
|
||||
<div class="card mb-3 border border-warning border-opacity-50">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-file-earmark-zip text-warning me-2"></i>Import Bambu Studio (.3mf)
|
||||
<span class="badge bg-warning text-dark ms-1">Optionnel</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<input type="file" id="file3mf" accept=".3mf" class="form-control" style="max-width:320px">
|
||||
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
|
||||
<i class="bi bi-magic me-1"></i>Extraire
|
||||
</button>
|
||||
<span id="parseStatus" class="text-muted small"></span>
|
||||
</div>
|
||||
<div class="form-text mt-1">Glissez votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="jobForm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-3">Donnees impression</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Nom de la commande</label>
|
||||
<input type="text" name="name" id="jobName" class="form-control" required placeholder="ex: Marqueurs competition salle X">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Fichier source</label>
|
||||
<input type="text" name="source_file" id="sourceFile" class="form-control" placeholder="nom_fichier.3mf">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Client</label>
|
||||
<select name="client_id" id="clientSelect" class="form-select">
|
||||
<option value="">Sans client</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" data-dm="{{ c.design_multiplier }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Matiere</label>
|
||||
<select name="material_id" id="materialSelect" class="form-select">
|
||||
<option value="">Prix global (parametres)</option>
|
||||
{% for m in materials %}
|
||||
<option value="{{ m.id }}" data-price="{{ m.price_per_kg }}" data-stock="{{ m.stock_g }}" data-threshold="{{ m.low_stock_threshold_g }}">
|
||||
{{ m.name }} ({{ m.type }}) - {{ m.price_per_kg }}EUR/kg
|
||||
{% if m.stock_g <= 0 %}[RUPTURE]{% elif m.stock_g <= m.low_stock_threshold_g %}[stock bas]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="stockInfo" class="form-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Poids filament (g)</label>
|
||||
<input type="number" name="weight_g" id="weight_g" class="form-control" step="0.01" min="0.1" required placeholder="423.97">
|
||||
<div class="form-text">Champ Modele dans Bambu</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Heures</label>
|
||||
<input type="number" name="hours" id="hours" class="form-control" min="0" value="0">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Minutes</label>
|
||||
<input type="number" name="minutes" id="minutes" class="form-control" min="0" max="59" value="0">
|
||||
<div class="form-text">Temps total Bambu</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-3">Parametres tarifaires</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
Coefficient design <span class="badge bg-secondary ms-1" id="dmLabel">x1.80</span>
|
||||
</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="setDM(0.80)">STL fourni (x0.80)</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="setDM(1.80)">Creation complete (x1.80)</button>
|
||||
</div>
|
||||
<input type="number" name="design_multiplier" id="design_multiplier" class="form-control form-control-sm" step="0.05" min="0" value="1.80">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold d-flex justify-content-between">
|
||||
Marge brute <span id="marginDisplay" class="text-muted">27.5 %</span>
|
||||
</label>
|
||||
<input type="range" name="gross_margin_pct" id="gross_margin_pct" class="form-range" min="20" max="35" step="0.5" value="27.5">
|
||||
<div class="d-flex justify-content-between" style="font-size:.75rem;color:#6b7280"><span>20%</span><span>35%</span></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Remise client (%)</label>
|
||||
<input type="number" name="discount_pct" id="discount_pct" class="form-control" min="0" max="100" step="0.5" value="0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes internes</label>
|
||||
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn w-100 py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-2"></i>Enregistrer la commande
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="card sticky-top" style="top:1.5rem">
|
||||
<div class="card-header py-3"><i class="bi bi-receipt me-2"></i>Recapitulatif temps reel</div>
|
||||
<div class="card-body" id="breakdown">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-calculator fs-2"></i>
|
||||
<p class="mt-2 small">Remplissez le poids et la duree pour voir le calcul.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function parse3mf() {
|
||||
const file = document.getElementById('file3mf').files[0];
|
||||
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
||||
const status = document.getElementById('parseStatus');
|
||||
status.textContent = 'Analyse...';
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
let filled = [];
|
||||
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
|
||||
if (d.hours != null){ document.getElementById('hours').value = d.hours; filled.push('heures'); }
|
||||
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
|
||||
if (d.filename) {
|
||||
document.getElementById('sourceFile').value = d.filename;
|
||||
if (!document.querySelector('[name=name]').value)
|
||||
document.querySelector('[name=name]').value = d.filename;
|
||||
}
|
||||
status.textContent = filled.length ? 'Extrait: '+filled.join(', ') : 'Donnees non trouvees';
|
||||
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
|
||||
recalc();
|
||||
})
|
||||
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color='#ef4444'; });
|
||||
}
|
||||
|
||||
document.getElementById('materialSelect').addEventListener('change', function() {
|
||||
const opt = this.options[this.selectedIndex];
|
||||
const stock = parseFloat(opt.dataset.stock)||0;
|
||||
const threshold = parseFloat(opt.dataset.threshold)||200;
|
||||
const info = document.getElementById('stockInfo');
|
||||
if (!opt.value) { info.textContent=''; return; }
|
||||
if (stock<=0) info.innerHTML='<span class="text-danger">RUPTURE DE STOCK</span>';
|
||||
else if (stock<=threshold) info.innerHTML='<span class="text-warning">Stock bas: '+stock.toFixed(0)+' g</span>';
|
||||
else info.innerHTML='<span class="text-success">Stock: '+stock.toFixed(0)+' g</span>';
|
||||
recalc();
|
||||
});
|
||||
|
||||
function setDM(val) {
|
||||
document.getElementById('design_multiplier').value = val;
|
||||
document.getElementById('dmLabel').textContent = 'x'+val.toFixed(2);
|
||||
recalc();
|
||||
}
|
||||
document.getElementById('gross_margin_pct').addEventListener('input', function(){
|
||||
document.getElementById('marginDisplay').textContent = this.value+' %'; recalc();
|
||||
});
|
||||
document.getElementById('design_multiplier').addEventListener('input', function(){
|
||||
document.getElementById('dmLabel').textContent = 'x'+parseFloat(this.value).toFixed(2); recalc();
|
||||
});
|
||||
document.getElementById('clientSelect').addEventListener('change', function(){
|
||||
const dm = this.options[this.selectedIndex].dataset.dm;
|
||||
if (dm) setDM(parseFloat(dm));
|
||||
});
|
||||
['weight_g','hours','minutes','discount_pct'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', recalc);
|
||||
});
|
||||
|
||||
function recalc() {
|
||||
const w = parseFloat(document.getElementById('weight_g').value)||0;
|
||||
const h = parseInt(document.getElementById('hours').value)||0;
|
||||
const m = parseInt(document.getElementById('minutes').value)||0;
|
||||
if (w<=0 || (h===0 && m===0)) return;
|
||||
const body = {
|
||||
weight_g: w, hours: h, minutes: m,
|
||||
design_multiplier: parseFloat(document.getElementById('design_multiplier').value)||1.80,
|
||||
gross_margin_pct: parseFloat(document.getElementById('gross_margin_pct').value)||27.5,
|
||||
discount_pct: parseFloat(document.getElementById('discount_pct').value)||0,
|
||||
material_id: document.getElementById('materialSelect').value||null,
|
||||
};
|
||||
fetch('/api/calculate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||||
.then(r=>r.json()).then(d=>renderBreakdown(d,body));
|
||||
}
|
||||
|
||||
function fmt(n) { return parseFloat(n).toFixed(4)+' EUR'; }
|
||||
function fmt2(n) { return parseFloat(n).toFixed(2)+' EUR'; }
|
||||
|
||||
function renderBreakdown(d, body) {
|
||||
const dis = body.discount_pct>0
|
||||
? '<div class="breakdown-row text-danger"><span>Remise ('+body.discount_pct+'%)</span></div>' : '';
|
||||
document.getElementById('breakdown').innerHTML = `
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Matiere</span><span>${fmt(d.material_cost)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Marge matiere (20%)</span><span>${fmt(d.material_margin)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Design (x${body.design_multiplier.toFixed(2)})</span><span>${fmt(d.design_cost)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Manutention</span><span>${fmt(d.handling_cost)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Usure machine</span><span>${fmt(d.wear_cost)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Electricite</span><span>${fmt(d.electricity_cost)}</span></div>
|
||||
<div class="breakdown-row subtotal"><span class="fw-semibold">Sous-total couts</span><span class="fw-semibold">${fmt2(d.subtotal)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Marge brute (${body.gross_margin_pct}%)</span><span>${fmt2(d.margin_amount)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted">Cotisations (12.3%)</span><span>${fmt2(d.tax_amount)}</span></div>
|
||||
${dis}
|
||||
<div class="breakdown-row total"><span>PRIX FINAL</span><span class="final-price">${fmt2(d.final_price)}</span></div>`;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Paramètres{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-sliders me-2 text-secondary"></i>Paramètres</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="row g-4">
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-boxes me-2"></i>Filament</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Prix du filament (€/kg)</label>
|
||||
<input type="number" name="filament_price_per_kg" class="form-control"
|
||||
step="0.01" value="{{ settings.filament_price_per_kg }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Marge matière (%)</label>
|
||||
<input type="number" name="material_margin_pct" class="form-control"
|
||||
step="0.5" value="{{ settings.material_margin_pct }}">
|
||||
<div class="form-text">Appliquée sur le coût matière brut.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-lightning-charge me-2"></i>Électricité</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Prix kWh (€)</label>
|
||||
<input type="number" name="electricity_price_kwh" class="form-control"
|
||||
step="0.001" value="{{ settings.electricity_price_kwh }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Puissance imprimante (kW)</label>
|
||||
<input type="number" name="printer_power_kw" class="form-control"
|
||||
step="0.01" value="{{ settings.printer_power_kw }}">
|
||||
<div class="form-text">Bambu X1C ≈ 0.35 kW en moyenne.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-gear me-2"></i>Usure machine</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Prix imprimante (€)</label>
|
||||
<input type="number" name="printer_price" class="form-control"
|
||||
step="1" value="{{ settings.printer_price }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Durée de vie (h)</label>
|
||||
<input type="number" name="printer_lifespan_hours" class="form-control"
|
||||
step="100" value="{{ settings.printer_lifespan_hours }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Prix buse (€)</label>
|
||||
<input type="number" name="nozzle_price" class="form-control"
|
||||
step="0.5" value="{{ settings.nozzle_price }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Durée de vie buse (h)</label>
|
||||
<input type="number" name="nozzle_lifespan_hours" class="form-control"
|
||||
step="10" value="{{ settings.nozzle_lifespan_hours }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Prix plateau (€)</label>
|
||||
<input type="number" name="plate_price" class="form-control"
|
||||
step="0.5" value="{{ settings.plate_price }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Durée de vie plateau (h)</label>
|
||||
<input type="number" name="plate_lifespan_hours" class="form-control"
|
||||
step="50" value="{{ settings.plate_lifespan_hours }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3"><i class="bi bi-hand-index me-2"></i>Manutention</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Taux horaire manutention (€/h)</label>
|
||||
<input type="number" name="handling_rate_per_hour" class="form-control"
|
||||
step="1" value="{{ settings.handling_rate_per_hour }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Temps par plateau (minutes)</label>
|
||||
<input type="number" name="handling_minutes_per_plate" class="form-control"
|
||||
step="1" value="{{ settings.handling_minutes_per_plate }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-percent me-2"></i>Fiscal</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Taux cotisations micro-entrepreneur (%)</label>
|
||||
<input type="number" name="tax_rate_pct" class="form-control"
|
||||
step="0.1" value="{{ settings.tax_rate_pct }}">
|
||||
<div class="form-text">12.3% pour vente de marchandises (2024).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn px-4 py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-2"></i>Enregistrer les paramètres
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Statistiques{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-bar-chart-line me-2 text-success"></i>Statistiques</h1>
|
||||
<a href="{{ url_for('export_csv') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download me-1"></i>Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Par client -->
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">CA par client</div>
|
||||
<div class="card-body p-0">
|
||||
{% if by_client %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Client</th><th>Commandes</th><th>Marge moy.</th><th class="text-end">CA total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set total_rev = by_client | sum(attribute='revenue') %}
|
||||
{% for c in by_client %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ c.name }}</td>
|
||||
<td>{{ c.jobs }}</td>
|
||||
<td><span class="badge bg-light text-dark">{{ c.avg_margin }}%</span></td>
|
||||
<td class="text-end">
|
||||
<span class="fw-bold" style="color:#ff6b35">{{ c.revenue }} €</span>
|
||||
{% if total_rev > 0 %}
|
||||
<div class="progress mt-1" style="height:4px">
|
||||
<div class="progress-bar" style="width:{{ (c.revenue / total_rev * 100)|round(0) }}%;background:#ff6b35"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">Aucune donnée disponible.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Par mois -->
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">CA par mois (12 derniers)</div>
|
||||
<div class="card-body p-0">
|
||||
{% if by_month %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Mois</th><th>Cmds</th><th class="text-end">CA</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set max_rev = by_month | map(attribute='revenue') | max %}
|
||||
{% for m in by_month %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ m.month }}</td>
|
||||
<td>{{ m.jobs }}</td>
|
||||
<td class="text-end">
|
||||
<span class="fw-bold">{{ m.revenue }} €</span>
|
||||
{% if max_rev > 0 %}
|
||||
<div class="progress mt-1" style="height:4px">
|
||||
<div class="progress-bar bg-success" style="width:{{ (m.revenue / max_rev * 100)|round(0) }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">Aucune donnée disponible.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user