commit 1f653d81600d2eb2204398d9583ff0826471c797 Author: jbperrin Date: Mon Jul 6 11:37:27 2026 +0200 Initial commit — 3D Pricing app diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7aacef --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000..db6855d Binary files /dev/null and b/__pycache__/app.cpython-310.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..6ef45b8 --- /dev/null +++ b/app.py @@ -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//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//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//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//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//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/') +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//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/') +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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4598c0d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..805b0f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask>=3.0.0 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..c9a8692 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,110 @@ + + + + + + {% block title %}3D Pricing{% endblock %} — 3D Pricing + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
+ {{ msg }} +
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + +{% endblock %} diff --git a/templates/clients.html b/templates/clients.html new file mode 100644 index 0000000..a403564 --- /dev/null +++ b/templates/clients.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} +{% block title %}Clients{% endblock %} + +{% block content %} + + +
+
+ {% if clients %} + + + + + + + + + {% for c in clients %} + + + + + + + + + {% endfor %} + +
NomEmailCoeff design par défautCommandesCA total
{{ c.name }}{{ c.email or '—' }} + {% if c.design_multiplier <= 0.80 %} + ×{{ c.design_multiplier }} (STL fourni) + {% else %} + ×{{ c.design_multiplier }} + {% endif %} + {{ c.job_count }}{{ c.total_revenue or 0 }} € +
+ + + +
+ +
+
+
+ {% else %} +
+ +

Aucun client enregistré.

+ Ajouter le premier client +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..0c76ceb Binary files /dev/null and b/templates/dashboard.html differ diff --git a/templates/job_detail.html b/templates/job_detail.html new file mode 100644 index 0000000..3f21660 --- /dev/null +++ b/templates/job_detail.html @@ -0,0 +1,102 @@ +{% extends 'base.html' %} +{% block title %}{{ job.name }}{% endblock %} + +{% block content %} + + +
+
+
+
Informations
+
+ + + + + + + + + + + + + +
Client{{ job.client_name or '—' }}
Matière + {% if job.material_name %} + {{ job.material_type }} {{ job.material_name }} + {% else %}—{% endif %} +
Fichier source{{ job.source_file or '—' }}
Date{{ job.created_at[:10] }}
Poids filament{{ job.weight_g }} g
Durée impression{{ job.print_time_s | fmt_time }}
Coeff design×{{ job.design_multiplier }}
Marge brute{{ job.gross_margin_pct }} %
Remise{{ job.discount_pct }} %
+ {% if job.notes %} +
+ {{ job.notes }} + {% endif %} +
+
+
+ +
+
+
Décomposition du prix
+
+
Matière{{ "%.4f"|format(job.material_cost) }} €
+
Marge matière{{ "%.4f"|format(job.material_margin) }} €
+
Design (×{{ job.design_multiplier }}){{ "%.4f"|format(job.design_cost) }} €
+
Manutention{{ "%.4f"|format(job.handling_cost) }} €
+
Usure machine{{ "%.4f"|format(job.wear_cost) }} €
+
Électricité{{ "%.4f"|format(job.electricity_cost) }} €
+
+ Sous-total coûts + {{ "%.2f"|format(job.subtotal) }} € +
+
+ Marge brute ({{ job.gross_margin_pct }}%) + {{ "%.2f"|format(job.margin_amount) }} € +
+
+ Cotisations micro-ent. + {{ "%.2f"|format(job.tax_amount) }} € +
+ {% if job.discount_pct > 0 %} +
+ Remise ({{ job.discount_pct }}%) + −{{ "%.2f"|format((job.subtotal + job.margin_amount + job.tax_amount) * job.discount_pct / 100) }} € +
+ {% endif %} +
+ PRIX FINAL + {{ "%.2f"|format(job.final_price) }} € +
+ + + {% set total = job.final_price %} + {% if total > 0 %} +
+
+ Coûts ({{ (job.subtotal/total*100)|round(1) }}%) + Marge ({{ (job.margin_amount/total*100)|round(1) }}%) + Taxes ({{ (job.tax_amount/total*100)|round(1) }}%) +
+
+
+
+
+
+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/jobs.html b/templates/jobs.html new file mode 100644 index 0000000..076238d --- /dev/null +++ b/templates/jobs.html @@ -0,0 +1,66 @@ +{% extends 'base.html' %} +{% block title %}Commandes{% endblock %} + +{% block content %} + + +
+
+ {% if jobs %} + + + + + + + + + + {% for j in jobs %} + + + + + + + + + + + + + {% endfor %} + +
DateNomClientPoidsDuréeDesignMargeRemisePrix final
{{ j.created_at[:10] }}{{ j.name }}{{ j.client_name or '—' }}{{ j.weight_g }} g{{ j.print_time_s | fmt_time }}×{{ j.design_multiplier }}{{ j.gross_margin_pct }}% + {% if j.discount_pct > 0 %} + -{{ j.discount_pct }}% + {% else %}—{% endif %} + {{ j.final_price }} € +
+ +
+ +
+
+
+ {% else %} +
+ +

Aucune commande enregistrée.

+ Créer le premier calcul +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/material_form.html b/templates/material_form.html new file mode 100644 index 0000000..a91ac3e --- /dev/null +++ b/templates/material_form.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% block title %}{% if material %}Modifier{% else %}Nouvelle{% endif %} matière{% endblock %} + +{% block content %} + + +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
1 rouleau ≈ 1000 g
+
+
+ + +
+
+ + +
+
+
+ + Annuler +
+
+
+
+
+ + {% if history %} +
+
+
Historique des prix
+
+ + + + {% for h in history %} + + + + + {% endfor %} + +
Date€/kg
{{ h.recorded_at[:10] }}{{ h.price_per_kg }} €
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/materials.html b/templates/materials.html new file mode 100644 index 0000000..358bf98 --- /dev/null +++ b/templates/materials.html @@ -0,0 +1,120 @@ +{% extends 'base.html' %} +{% block title %}Matières{% endblock %} + +{% block content %} + + +
+
+ {% if materials %} + + + + + + + + + {% for m in materials %} + {% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %} + + + + + + + + + + + + + + + {% endfor %} + +
NomTypeCouleurPrix/kgStockCommandesUtilisé total
{{ m.name }}{% if m.brand %} / {{ m.brand }}{% endif %}{{ m.type }} + {% if m.color %} + + + {{ m.color }} + + {% else %}—{% endif %} + {{ m.price_per_kg }} € + {% if m.stock_g <= 0 %} + Rupture + {% elif m.stock_g <= m.low_stock_threshold_g %} +
+ {{ m.stock_g|round(0)|int }} g +
+ {% else %} + {{ m.stock_g|round(0)|int }} g + {% endif %} +
+
+
+
+
{{ m.job_count }}{{ m.total_used_g }} g +
+ + + + +
+ +
+
+
+ {% else %} +
+ +

Aucune matière enregistrée.

+ + Ajouter votre premier filament + +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/new_job.html b/templates/new_job.html new file mode 100644 index 0000000..6960436 --- /dev/null +++ b/templates/new_job.html @@ -0,0 +1,233 @@ +{% extends 'base.html' %} +{% block title %}Nouveau calcul{% endblock %} + +{% block content %} + + +
+
+ +
+
+ Import Bambu Studio (.3mf) + Optionnel +
+
+
+ + + +
+
Glissez votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.
+
+
+ +
+
+
Donnees impression
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
Champ Modele dans Bambu
+
+
+ + +
+
+ + +
Temps total Bambu
+
+
+
+
+ +
+
Parametres tarifaires
+
+
+ +
+ + +
+ +
+
+ + +
20%35%
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
Recapitulatif temps reel
+
+
+ +

Remplissez le poids et la duree pour voir le calcul.

+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..0142266 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,132 @@ +{% extends 'base.html' %} +{% block title %}Paramètres{% endblock %} + +{% block content %} + + +
+
+ +
+
+
Filament
+
+
+ + +
+
+ + +
Appliquée sur le coût matière brut.
+
+
+
+
+ +
+
+
Électricité
+
+
+ + +
+
+ + +
Bambu X1C ≈ 0.35 kW en moyenne.
+
+
+
+
+ +
+
+
Usure machine
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
Manutention
+
+
+ + +
+
+ + +
+
+
+ +
+
Fiscal
+
+
+ + +
12.3% pour vente de marchandises (2024).
+
+
+
+
+ +
+ +
+ +
+
+{% endblock %} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..7e227fb --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} +{% block title %}Statistiques{% endblock %} + +{% block content %} + + +
+ +
+
+
CA par client
+
+ {% if by_client %} + + + + + + {% set total_rev = by_client | sum(attribute='revenue') %} + {% for c in by_client %} + + + + + + + {% endfor %} + +
ClientCommandesMarge moy.CA total
{{ c.name }}{{ c.jobs }}{{ c.avg_margin }}% + {{ c.revenue }} € + {% if total_rev > 0 %} +
+
+
+ {% endif %} +
+ {% else %} +
Aucune donnée disponible.
+ {% endif %} +
+
+
+ + +
+
+
CA par mois (12 derniers)
+
+ {% if by_month %} + + + + + + {% set max_rev = by_month | map(attribute='revenue') | max %} + {% for m in by_month %} + + + + + + {% endfor %} + +
MoisCmdsCA
{{ m.month }}{{ m.jobs }} + {{ m.revenue }} € + {% if max_rev > 0 %} +
+
+
+ {% endif %} +
+ {% else %} +
Aucune donnée disponible.
+ {% endif %} +
+
+
+
+{% endblock %}