From 1f653d81600d2eb2204398d9583ff0826471c797 Mon Sep 17 00:00:00 2001 From: jbperrin Date: Mon, 6 Jul 2026 11:37:27 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=203D=20Pricing=20a?= =?UTF-8?q?pp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 16 ++ __pycache__/app.cpython-310.pyc | Bin 0 -> 18447 bytes app.py | 449 ++++++++++++++++++++++++++++++++ docker-compose.yml | 26 ++ requirements.txt | 1 + templates/base.html | 110 ++++++++ templates/client_form.html | 64 +++++ templates/clients.html | 60 +++++ templates/dashboard.html | Bin 0 -> 3596 bytes templates/job_detail.html | 102 ++++++++ templates/jobs.html | 66 +++++ templates/material_form.html | 100 +++++++ templates/materials.html | 120 +++++++++ templates/new_job.html | 233 +++++++++++++++++ templates/settings.html | 132 ++++++++++ templates/stats.html | 84 ++++++ 16 files changed, 1563 insertions(+) create mode 100644 Dockerfile create mode 100644 __pycache__/app.cpython-310.pyc create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 templates/base.html create mode 100644 templates/client_form.html create mode 100644 templates/clients.html create mode 100644 templates/dashboard.html create mode 100644 templates/job_detail.html create mode 100644 templates/jobs.html create mode 100644 templates/material_form.html create mode 100644 templates/materials.html create mode 100644 templates/new_job.html create mode 100644 templates/settings.html create mode 100644 templates/stats.html 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 0000000000000000000000000000000000000000..db6855d0e128c0cb8a7d8ea00ec16f7dd636eb55 GIT binary patch literal 18447 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c76ceb979aa88e321d8ad1ae22b9531625daaed GIT binary patch literal 3596 zcmd5{>W1#4vA5ioo z8lZpSKgpMLh7`40dyy1DfK~@DMUlhDH#2X>H>V)3z0kFTS;?K4uYId#aC&=ka|)$0 z)m!k=D}ggFxm%Zp+Zw;%hWHT|?pIbuqbX0jm$lqLrMPpKS;rf})`Hi-pX5uK?3EHq$y_HyZCZhAE}#|c0zBS? zZMoG_H(9=1$b5vnT%ZLq#9?j4>N2~zf!?ZOW$f8Zi*0db%Go*G-e%(=UPI|Mb8QCB z@07UAN?yHftm$?A?3>l<`|{~`KU78;OK+=y6A!qDvQxcdsv&?+BBs}--w57A#Z}cS z!nEM|$#Q{&{}M+Cy!nmtW0CZ8NyV-@;s z_=YD=zA9b{mE|v$Q)U8^A^3mF09xH-3u@l9wUG7;kUmh z7RKRzFgg1RfcD07H5w<(AA=lL|F@7$+j3ote@}D7jPdCb$CK-h!~Nh3^81S0MnGwM zPoc4!kB5AUXTE8Q7B@?sFvIC&^Eey^!%qzU=So;F90nPQ4{B0K1k@b40%f*EoWCbA zoHn6);NPSGNopQS3N*Y!x!C8?y_^--C|oV~(rD(brkkVElI}i&D9*?gGDN4ucfv7DeE9Xx<1&ah7I<6b4uvq1s-%9K zdwF|GZh=dG|oMMPheJN0t@BXs5u}T^6K;TA=nx-&Kes=MIZnu}j z0P(&)S5)HIS|ZVQ7s|*`IgS8Mw+zlZR!AsG56Qy{GE|Z9V|tH73g!eBzM9)zmj8lt zurbn(RAK@LPN#`XB$TG!LB}2sG9CjU=)&OHrROD9>Y?h!m++UZ%?90u0iQOb%RE)& zgT)Ms@7j-5g4Wm^*X6HGyIkNejbA859VdR2)mjR?4XzCVNNmwVo8K5& zW6TVuUiS9x{kU_4LRB35i!6U*)Xq7CEx;omUNiv+thHELYH%$laSaDM)7wfED7T@f z_!t9W{Z>%W7EMsaBgaLnokig+ylh3`;2o^m_;Q@u1B4ifvYJ(m+?ehZcQz}9-hnfB1_diHP4oSebNtj13O{zg~(LQHzHEBppAX*)9v$ z%E5WDL64ym9f>%QkCdAC%Bs|*!F+b95l+jx#CPl2MV3GBs~+_NKmfMXi`g_`*nBu!N5ptG39IE?-JpwdL!pwp?urjmQu(?r6@|CPS_KR^Ef?QJvG literal 0 HcmV?d00001 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 %}