From bd6f244260b66644d7f56a4d20ae56abf1b1a471 Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 6 Jul 2026 16:15:14 +0200 Subject: [PATCH] =?UTF-8?q?Planning=20complet=20:=20multi-pi=C3=A8ces,=20c?= =?UTF-8?q?apacit=C3=A9,=20Gantt,=20iCal,=20horaires,=20imprimantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 643 ++++++++++++++++++++++++++++---- templates/base.html | 3 + templates/calendar_blocks.html | 91 +++++ templates/new_job.html | 65 +++- templates/planning.html | 378 +++++++++++++++++++ templates/printers.html | 83 +++++ templates/settings.html | 24 ++ templates/working_schedule.html | 97 +++++ 8 files changed, 1308 insertions(+), 76 deletions(-) create mode 100644 templates/calendar_blocks.html create mode 100644 templates/planning.html create mode 100644 templates/printers.html create mode 100644 templates/working_schedule.html diff --git a/app.py b/app.py index a76d6da..b0c83f0 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, Response -import sqlite3, csv, io, os, zipfile, re +import sqlite3, csv, io, os, zipfile, re, math +from datetime import datetime, timedelta, date as date_cls app = Flask(__name__) DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db') @@ -81,15 +82,51 @@ def init_db(): 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, + pieces_per_plate INTEGER DEFAULT 1, order_qty INTEGER DEFAULT 1, material_cost REAL, material_margin REAL, design_cost REAL, handling_cost REAL, wear_cost REAL, electricity_cost REAL, cout_fixe REAL, total_marge REAL, subtotal REAL, margin_amount REAL, cotisations_amount REAL, vfl_amount REAL, tva_amount REAL, - tax_amount REAL, final_price REAL, + other_taxes_amount REAL, + tax_amount REAL, price_per_piece REAL, final_price REAL, notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS printers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS working_schedule ( + day_of_week INTEGER PRIMARY KEY, + mode TEXT DEFAULT 'onsite', + tt_window_start TEXT DEFAULT '10:00', + tt_window_end TEXT DEFAULT '22:00', + onsite_slot_1 TEXT DEFAULT '07:00', + onsite_slot_2 TEXT DEFAULT '18:30' + ); + CREATE TABLE IF NOT EXISTS calendar_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + type TEXT DEFAULT 'off', + printer_id INTEGER, + notes TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS print_slots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER REFERENCES jobs(id) ON DELETE CASCADE, + printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, + planned_start TEXT NOT NULL, + planned_end TEXT NOT NULL, + status TEXT DEFAULT 'planned', + failure_note 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'), @@ -102,10 +139,26 @@ def init_db(): ('tva_rate_pct','20.0'), ('other_taxes_rate_pct','1.0'), ('printer_power_kw','0.35'), + ('cooldown_minutes','15'), + ('failure_buffer_pct','10'), + ('max_simultaneous_printers','5'), ] conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults) - # Migrations : ajout de colonnes si elles n'existent pas (upgrade DB existante) + # Default printers: 6 A1, #1-#5 active, #6 inactive + for i in range(1, 7): + conn.execute('INSERT OR IGNORE INTO printers(id,name,is_active) VALUES(?,?,?)', + (i, f'A1 #{i}', 1 if i <= 5 else 0)) + + # Default working schedule: Mon/Wed/Fri = TT, others = onsite + tt_days = {0, 2, 4} + for dow in range(7): + mode = 'tt' if dow in tt_days else 'onsite' + conn.execute('''INSERT OR IGNORE INTO working_schedule + (day_of_week,mode,tt_window_start,tt_window_end,onsite_slot_1,onsite_slot_2) + VALUES(?,?,?,?,?,?)''', (dow, mode, '10:00', '22:00', '07:00', '18:30')) + + # Migrations migrations = [ 'ALTER TABLE jobs ADD COLUMN machine_profile_id INTEGER', 'ALTER TABLE jobs ADD COLUMN handling_profile_id INTEGER', @@ -117,12 +170,15 @@ def init_db(): 'ALTER TABLE jobs ADD COLUMN vfl_amount REAL', 'ALTER TABLE jobs ADD COLUMN tva_amount REAL', 'ALTER TABLE jobs ADD COLUMN other_taxes_amount REAL', + 'ALTER TABLE jobs ADD COLUMN pieces_per_plate INTEGER DEFAULT 1', + 'ALTER TABLE jobs ADD COLUMN order_qty INTEGER DEFAULT 1', + 'ALTER TABLE jobs ADD COLUMN price_per_piece REAL', ] for sql in migrations: try: conn.execute(sql) except Exception: - pass # colonne deja existante + pass conn.commit(); conn.close() @@ -178,32 +234,18 @@ def parse_3mf(file_stream): return result def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, - price_per_kg_override=None, s=None): - """ - Decomposition: - Cout fixe = matiere + marge_matiere + usure + electricite - Manutention = handling (fixe par plateau) - Design = matiere × coeff_design - Sous-total = cout_fixe + handling + design - Marge brute = sous-total × gross_margin_pct - Total marge = handling + design + marge_brute - Prix HT base = cout_fixe + total_marge (= sous-total + marge) - Charges (sur CA HT) : - cotisations = prix_ht / (1 - cot - vfl) × cot - vfl = prix_ht / (1 - cot - vfl) × vfl - Prix HT net = prix_ht / (1 - cot - vfl) - TVA = prix_ht_net × tva_rate - Prix TTC = prix_ht_net × (1 + tva_rate) - Prix final = prix_ttc × (1 - discount) - """ + price_per_kg_override=None, s=None, pieces_per_plate=1): if s is None: s = get_settings() + if pieces_per_plate < 1: + pieces_per_plate = 1 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'] + # Manutention divisée par le nombre de pièces sur le plateau + handling = (s['handling_rate_per_hour'] / 60) * s['handling_minutes_per_plate'] / pieces_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 @@ -214,7 +256,7 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, sous_total = cout_fixe + handling + design margin = sous_total * (gross_margin_pct / 100) total_marge= handling + design + margin - prix_ht = cout_fixe + total_marge # = sous_total + margin + prix_ht = cout_fixe + total_marge cot_rate = s.get('cotisations_rate_pct', 12.3) / 100 vfl_rate = s.get('vfl_rate_pct', 0.0) / 100 @@ -222,8 +264,6 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, other_taxes_rate = s.get('other_taxes_rate_pct', 1.0) / 100 total_charges = cot_rate + vfl_rate + other_taxes_rate - # Charges calculées sur le CA (prix de vente HT net) - # prix_ht_net = prix_ht / (1 - total_charges) so that charges = prix_ht_net * total_charges if total_charges < 1.0: prix_ht_net = prix_ht / (1 - total_charges) else: @@ -235,12 +275,9 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, tva_amount = prix_ht_net * tva_rate prix_ttc = prix_ht_net + tva_amount final = prix_ttc * (1 - discount_pct / 100) - - # % de marge effective sur le prix HT final - marge_pct_on_ht = (total_marge / prix_ht_net * 100) if prix_ht_net > 0 else 0 + marge_pct_on_ht = (total_marge / prix_ht_net * 100) if prix_ht_net > 0 else 0 return { - # Lignes détaillées 'material_cost': round(mat, 4), 'material_margin': round(mat_margin, 4), 'wear_cost': round(wear, 4), @@ -257,17 +294,14 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, 'total_marge': round(total_marge, 4), 'marge_pct_on_ht': round(marge_pct_on_ht, 1), 'prix_ht': round(prix_ht, 4), - # Fiscal - 'cotisations_amount': round(cot_amount, 4), - 'vfl_amount': round(vfl_amount, 4), - 'other_taxes_amount': round(other_taxes_amount, 4), - 'tva_amount': round(tva_amount, 4), - 'prix_ht_net': round(prix_ht_net, 4), - 'prix_ttc': round(prix_ttc, 4), - # Compat + 'cotisations_amount': round(cot_amount, 4), + 'vfl_amount': round(vfl_amount, 4), + 'other_taxes_amount': round(other_taxes_amount, 4), + 'tva_amount': round(tva_amount, 4), + 'prix_ht_net': round(prix_ht_net, 4), + 'prix_ttc': round(prix_ttc, 4), 'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4), 'final_price': round(final, 2), - # Taux et paramètres appliqués (pour affichage) '_cot_pct': round(cot_rate * 100, 2), '_vfl_pct': round(vfl_rate * 100, 2), '_tva_pct': round(tva_rate * 100, 2), @@ -276,8 +310,167 @@ def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct, '_handling_rate': s['handling_rate_per_hour'], '_handling_minutes': s['handling_minutes_per_plate'], '_elec_price': s['electricity_price_kwh'], + '_pieces_per_plate': pieces_per_plate, } +# ── Planning helpers ────────────────────────────────────────────────────────── + +def get_schedule_dict(): + conn = get_db() + rows = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() + conn.close() + return {r['day_of_week']: dict(r) for r in rows} + +def get_blocks_dict(from_date=None, to_date=None): + conn = get_db() + if from_date and to_date: + rows = conn.execute( + 'SELECT * FROM calendar_blocks WHERE date BETWEEN ? AND ? ORDER BY date', + (str(from_date), str(to_date))).fetchall() + else: + rows = conn.execute('SELECT * FROM calendar_blocks ORDER BY date').fetchall() + conn.close() + return [dict(r) for r in rows] + +def get_day_launches(check_date, schedule, blocks, cycle_min): + """Returns number of possible launches on a given date (per machine).""" + date_str = str(check_date) + dow = check_date.weekday() + block = next((b for b in blocks if b['date'] == date_str and b['printer_id'] is None), None) + mode = block['type'] if block else schedule[dow]['mode'] + + if mode == 'off': + return 0, mode + elif mode == 'tt': + sched = schedule[dow] + h0, m0 = [int(x) for x in sched['tt_window_start'].split(':')] + h1, m1 = [int(x) for x in sched['tt_window_end'].split(':')] + window_min = (h1 * 60 + m1) - (h0 * 60 + m0) + launches = max(1, int(window_min / cycle_min)) if cycle_min > 0 else 1 + return launches, mode + elif mode == 'onsite': + return 2, mode + return 0, mode + +def calc_capacity(print_time_s, pieces_per_plate, days_ahead=30): + """Calcule la capacité de production sur N jours à venir.""" + s = get_settings() + cooldown_min = s.get('cooldown_minutes', 15) + max_printers = int(s.get('max_simultaneous_printers', 5)) + failure_buf = s.get('failure_buffer_pct', 10) / 100 + + cycle_min = print_time_s / 60 + cooldown_min + if cycle_min <= 0: + cycle_min = 1 + + conn = get_db() + active_count = conn.execute('SELECT COUNT(*) FROM printers WHERE is_active=1').fetchone()[0] + conn.close() + effective = min(active_count, max_printers) + + schedule = get_schedule_dict() + today = date_cls.today() + blocks = get_blocks_dict(today, today + timedelta(days=days_ahead)) + + pieces_per_day_first = None + week_total = 0 + month_total = 0 + + for offset in range(days_ahead): + d = today + timedelta(days=offset) + launches, _ = get_day_launches(d, schedule, blocks, cycle_min) + pieces = launches * pieces_per_plate * effective + if pieces_per_day_first is None and pieces > 0: + pieces_per_day_first = pieces + if offset < 7: + week_total += pieces + month_total += pieces + + # Nb plateaux pour 1 commande (calculé côté appelant) + return { + 'pieces_per_day': pieces_per_day_first or 0, + 'pieces_per_week': week_total, + 'pieces_per_month': month_total, + 'effective_printers': effective, + 'cycle_minutes': round(cycle_min, 1), + 'failure_buffer_pct': round(failure_buf * 100, 1), + } + +def find_next_slot(print_time_s): + """Trouve le prochain créneau libre sur une imprimante disponible.""" + s = get_settings() + cooldown_min = int(s.get('cooldown_minutes', 15)) + max_printers = int(s.get('max_simultaneous_printers', 5)) + total_sec = print_time_s + cooldown_min * 60 + + conn = get_db() + printers = conn.execute( + 'SELECT * FROM printers WHERE is_active=1 ORDER BY id LIMIT ?', (max_printers,) + ).fetchall() + existing = conn.execute( + "SELECT printer_id, planned_start, planned_end FROM print_slots WHERE status NOT IN ('cancelled','failed')" + ).fetchall() + conn.close() + + schedule = get_schedule_dict() + today = date_cls.today() + blocks = get_blocks_dict(today, today + timedelta(days=60)) + + # Build busy intervals per printer + busy = {} + for sl in existing: + pid = sl['printer_id'] + if pid not in busy: + busy[pid] = [] + busy[pid].append((sl['planned_start'], sl['planned_end'])) + + for offset in range(60): + d = today + timedelta(days=offset) + launches, mode = get_day_launches(d, schedule, blocks, (print_time_s / 60) + cooldown_min) + if launches == 0: + continue + + date_str = str(d) + sched = schedule[d.weekday()] + + # Build candidate launch times + candidates = [] + if mode == 'tt': + h0, m0 = [int(x) for x in sched['tt_window_start'].split(':')] + candidates.append(datetime(d.year, d.month, d.day, h0, m0)) + # Add slots every cycle within window + h1, m1 = [int(x) for x in sched['tt_window_end'].split(':')] + window_end = datetime(d.year, d.month, d.day, h1, m1) + t = candidates[0] + while True: + t = t + timedelta(seconds=total_sec) + if t >= window_end: + break + candidates.append(t) + else: + h0, m0 = [int(x) for x in sched['onsite_slot_1'].split(':')] + h1, m1 = [int(x) for x in sched['onsite_slot_2'].split(':')] + candidates.append(datetime(d.year, d.month, d.day, h0, m0)) + candidates.append(datetime(d.year, d.month, d.day, h1, m1)) + + for start_dt in candidates: + end_dt = start_dt + timedelta(seconds=total_sec) + for printer in printers: + pid = printer['id'] + printer_busy = busy.get(pid, []) + conflict = any( + not (end_dt.isoformat() <= bs or start_dt.isoformat() >= be) + for bs, be in printer_busy + ) + if not conflict: + return { + 'printer_id': pid, + 'printer_name': printer['name'], + 'planned_start': start_dt.strftime('%Y-%m-%dT%H:%M'), + 'planned_end': end_dt.strftime('%Y-%m-%dT%H:%M'), + } + return None + def fmt_time(s): return f"{s//3600}h{(s%3600)//60:02d}m" app.jinja_env.filters['fmt_time'] = fmt_time @@ -299,8 +492,16 @@ def dashboard(): FROM jobs''').fetchone() low_stock = conn.execute( 'SELECT * FROM materials WHERE stock_g<=low_stock_threshold_g ORDER BY stock_g ASC').fetchall() + # Restock alerts + planned_consumption = conn.execute(''' + SELECT j.material_id, SUM(j.weight_g * j.order_qty) as total_g + FROM print_slots ps + JOIN jobs j ON ps.job_id=j.id + WHERE ps.status='planned' AND j.material_id IS NOT NULL + GROUP BY j.material_id''').fetchall() conn.close() - return render_template('dashboard.html', jobs=recent, stats=stats, low_stock=low_stock) + return render_template('dashboard.html', jobs=recent, stats=stats, + low_stock=low_stock, planned_consumption=planned_consumption) # ─── Clients ────────────────────────────────────────────────────────────────── @@ -333,7 +534,8 @@ def edit_client(id): if request.method == 'POST': conn.execute('UPDATE clients SET name=?,email=?,design_multiplier=?,notes=? WHERE id=?', (request.form['name'], request.form.get('email',''), - float(request.form.get('design_multiplier', client['design_multiplier'])), request.form.get('notes',''), id)) + float(request.form.get('design_multiplier', client['design_multiplier'])), + request.form.get('notes',''), id)) conn.commit(); conn.close() return redirect(url_for('clients')) conn.close() @@ -492,8 +694,7 @@ def delete_machine_profile(id): def new_handling_profile(): if request.method == 'POST': conn = get_db() - conn.execute('''INSERT INTO handling_profiles(name,handling_rate_per_hour,handling_minutes_per_plate,notes) - VALUES(?,?,?,?)''', ( + conn.execute('INSERT INTO handling_profiles(name,handling_rate_per_hour,handling_minutes_per_plate,notes) VALUES(?,?,?,?)', ( request.form['name'], float(request.form['handling_rate_per_hour']), float(request.form['handling_minutes_per_plate']), @@ -508,8 +709,7 @@ def edit_handling_profile(id): conn = get_db() profile = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(id,)).fetchone() if request.method == 'POST': - conn.execute('''UPDATE handling_profiles SET name=?,handling_rate_per_hour=?, - handling_minutes_per_plate=?,notes=? WHERE id=?''', ( + conn.execute('UPDATE handling_profiles SET name=?,handling_rate_per_hour=?,handling_minutes_per_plate=?,notes=? WHERE id=?', ( request.form['name'], float(request.form['handling_rate_per_hour']), float(request.form['handling_minutes_per_plate']), @@ -605,9 +805,12 @@ def delete_pricing_profile(id): def jobs(): conn = get_db() jobs = conn.execute(''' - SELECT j.*, c.name as client_name, m.name as material_name + SELECT j.*, c.name as client_name, m.name as material_name, + ps.status as slot_status, ps.planned_start FROM jobs j LEFT JOIN clients c ON j.client_id=c.id LEFT JOIN materials m ON j.material_id=m.id + LEFT JOIN print_slots ps ON ps.job_id=j.id AND ps.id=( + SELECT id FROM print_slots WHERE job_id=j.id ORDER BY planned_start LIMIT 1) ORDER BY j.created_at DESC''').fetchall() conn.close() return render_template('jobs.html', jobs=jobs) @@ -624,20 +827,22 @@ def new_job(): settings = get_settings() if request.method == 'POST': - weight_g = float(request.form['weight_g']) - hours = int(request.form.get('hours',0)) - minutes = int(request.form.get('minutes',0)) - print_time_s = hours*3600 + minutes*60 - design_mult = float(request.form['design_multiplier']) - gross_margin = float(request.form['gross_margin_pct']) - discount = float(request.form.get('discount_pct',0)) - client_id = request.form.get('client_id') or None - material_id = request.form.get('material_id') or None - source_file = request.form.get('source_file','').strip() - machine_pid = request.form.get('machine_profile_id') or None - handling_pid = request.form.get('handling_profile_id') or None - material_pid = request.form.get('material_profile_id') or None - pricing_pid = request.form.get('pricing_profile_id') or None + weight_g = float(request.form['weight_g']) + hours = int(request.form.get('hours',0)) + minutes = int(request.form.get('minutes',0)) + print_time_s = hours*3600 + minutes*60 + design_mult = float(request.form['design_multiplier']) + gross_margin = float(request.form['gross_margin_pct']) + discount = float(request.form.get('discount_pct',0)) + client_id = request.form.get('client_id') or None + material_id = request.form.get('material_id') or None + source_file = request.form.get('source_file','').strip() + machine_pid = request.form.get('machine_profile_id') or None + handling_pid = request.form.get('handling_profile_id') or None + material_pid = request.form.get('material_profile_id') or None + pricing_pid = request.form.get('pricing_profile_id') or None + pieces_per_plate = max(1, int(request.form.get('pieces_per_plate', 1))) + order_qty = max(1, int(request.form.get('order_qty', 1))) s = dict(settings) if machine_pid: @@ -667,22 +872,27 @@ def new_job(): if mat: price_kg = mat['price_per_kg'] - r = calc(weight_g, print_time_s, design_mult, gross_margin, discount, price_kg, s) + r = calc(weight_g, print_time_s, design_mult, gross_margin, discount, + price_kg, s, pieces_per_plate) conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id, material_profile_id,pricing_profile_id,source_file,name,description, weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct, + pieces_per_plate,order_qty, material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost, cout_fixe,total_marge,subtotal,margin_amount, - cotisations_amount,vfl_amount,tva_amount,tax_amount,final_price,notes) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', + cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount, + price_per_piece,final_price,notes) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', (client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid, source_file,request.form['name'],request.form.get('description',''), weight_g,print_time_s,design_mult,gross_margin,discount, + pieces_per_plate,order_qty, r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'], r['wear_cost'],r['electricity_cost'], r['cout_fixe'],r['total_marge'],r['subtotal'],r['margin_amount'], - r['cotisations_amount'],r['vfl_amount'],r['tva_amount'],r['tax_amount'], - r['final_price'],request.form.get('notes',''))) + r['cotisations_amount'],r['vfl_amount'],r['tva_amount'],r['other_taxes_amount'], + r['tax_amount'],r['final_price'],round(r['final_price'],2), + 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() @@ -709,10 +919,14 @@ def job_detail(id): LEFT JOIN material_profiles marp ON j.material_profile_id=marp.id LEFT JOIN pricing_profiles pp ON j.pricing_profile_id=pp.id WHERE j.id=?''', (id,)).fetchone() + slots = conn.execute(''' + SELECT ps.*, p.name as printer_name FROM print_slots ps + LEFT JOIN printers p ON ps.printer_id=p.id + WHERE ps.job_id=? ORDER BY ps.planned_start''', (id,)).fetchall() conn.close() if not job: return redirect(url_for('jobs')) - return render_template('job_detail.html', job=job) + return render_template('job_detail.html', job=job, slots=slots) @app.route('/jobs//delete', methods=['POST']) def delete_job(id): @@ -721,6 +935,95 @@ def delete_job(id): conn.commit(); conn.close() return redirect(url_for('jobs')) +# ─── Planning ───────────────────────────────────────────────────────────────── + +@app.route('/planning') +def planning(): + conn = get_db() + printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall() + unscheduled = conn.execute(''' + SELECT j.*, c.name as client_name + FROM jobs j LEFT JOIN clients c ON j.client_id=c.id + WHERE j.id NOT IN (SELECT DISTINCT job_id FROM print_slots WHERE status NOT IN ('cancelled','failed')) + ORDER BY j.created_at DESC LIMIT 20''').fetchall() + blocks = conn.execute( + "SELECT * FROM calendar_blocks WHERE date >= date('now') ORDER BY date LIMIT 60" + ).fetchall() + schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() + conn.close() + return render_template('planning.html', printers=printers, + unscheduled=unscheduled, blocks=blocks, schedule=schedule) + +@app.route('/printers', methods=['GET','POST']) +def printers_page(): + conn = get_db() + if request.method == 'POST': + name = request.form['name'] + active = 1 if request.form.get('is_active') else 0 + notes = request.form.get('notes','') + conn.execute('INSERT INTO printers(name,is_active,notes) VALUES(?,?,?)', (name,active,notes)) + conn.commit() + printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall() + conn.close() + return render_template('printers.html', printers=printers) + +@app.route('/printers//toggle', methods=['POST']) +def toggle_printer(id): + conn = get_db() + conn.execute('UPDATE printers SET is_active=1-is_active WHERE id=?', (id,)) + conn.commit(); conn.close() + return redirect(url_for('printers_page')) + +@app.route('/printers//delete', methods=['POST']) +def delete_printer(id): + conn = get_db() + conn.execute('DELETE FROM printers WHERE id=?', (id,)) + conn.commit(); conn.close() + return redirect(url_for('printers_page')) + +@app.route('/working-schedule', methods=['GET','POST']) +def working_schedule(): + if request.method == 'POST': + conn = get_db() + for dow in range(7): + mode = request.form.get(f'mode_{dow}', 'off') + tt_start = request.form.get(f'tt_start_{dow}', '10:00') + tt_end = request.form.get(f'tt_end_{dow}', '22:00') + onsite_slot1 = request.form.get(f'onsite_slot1_{dow}', '07:00') + onsite_slot2 = request.form.get(f'onsite_slot2_{dow}', '18:30') + conn.execute('''UPDATE working_schedule SET mode=?,tt_window_start=?,tt_window_end=?, + onsite_slot_1=?,onsite_slot_2=? WHERE day_of_week=?''', + (mode, tt_start, tt_end, onsite_slot1, onsite_slot2, dow)) + conn.commit(); conn.close() + return redirect(url_for('working_schedule')) + conn = get_db() + schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() + conn.close() + return render_template('working_schedule.html', schedule=schedule) + +@app.route('/calendar-blocks', methods=['GET','POST']) +def calendar_blocks(): + conn = get_db() + if request.method == 'POST': + conn.execute('INSERT INTO calendar_blocks(date,type,printer_id,notes) VALUES(?,?,?,?)', ( + request.form['date'], + request.form.get('type','off'), + request.form.get('printer_id') or None, + request.form.get('notes','') + )) + conn.commit() + blocks = conn.execute("SELECT cb.*, p.name as printer_name FROM calendar_blocks cb LEFT JOIN printers p ON cb.printer_id=p.id ORDER BY cb.date DESC LIMIT 50").fetchall() + printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall() + conn.close() + return render_template('calendar_blocks.html', blocks=blocks, printers=printers) + +@app.route('/calendar-blocks//delete', methods=['POST']) +def delete_calendar_block(id): + conn = get_db() + conn.execute('DELETE FROM calendar_blocks WHERE id=?', (id,)) + conn.commit(); conn.close() + return redirect(url_for('calendar_blocks')) + # ─── API ────────────────────────────────────────────────────────────────────── @app.route('/api/calculate', methods=['POST']) @@ -759,13 +1062,36 @@ def api_calculate(): price_kg = mat['price_per_kg'] conn.close() + pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1))) + order_qty = max(1, int(d.get('order_qty', 1))) + print_time_s = int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60 + result = calc( weight_g=float(d['weight_g']), - print_time_s=int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60, + print_time_s=print_time_s, design_mult=float(d.get('design_multiplier',1.80)), gross_margin_pct=float(d.get('gross_margin_pct',27.5)), discount_pct=float(d.get('discount_pct',0)), - price_per_kg_override=price_kg, s=s) + price_per_kg_override=price_kg, s=s, + pieces_per_plate=pieces_per_plate) + + # Multi-piece extras + nb_plateaux = math.ceil(order_qty / pieces_per_plate) + price_order = round(result['final_price'] * order_qty, 2) + last_plate_qty = order_qty % pieces_per_plate + plate_full = last_plate_qty == 0 + + cooldown_min = s.get('cooldown_minutes', 15) + total_print_min = (print_time_s / 60 + cooldown_min) * nb_plateaux - cooldown_min + capacity = calc_capacity(print_time_s, pieces_per_plate) + + result['order_qty'] = order_qty + result['nb_plateaux'] = nb_plateaux + result['price_order'] = price_order + result['plate_full'] = plate_full + result['last_plate_qty'] = last_plate_qty + result['total_print_min']= round(total_print_min, 0) + result['capacity'] = capacity return jsonify(result) @app.route('/api/clients/') @@ -788,6 +1114,175 @@ def api_parse_3mf(): result['filename'] = os.path.splitext(f.filename)[0] return jsonify(result) +@app.route('/api/slots') +def api_slots(): + conn = get_db() + slots = conn.execute(''' + SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end, + ps.status, ps.failure_note, + j.name as job_name, j.print_time_s, j.final_price, + j.pieces_per_plate, j.order_qty, j.weight_g, + p.name as printer_name, + c.name as client_name + FROM print_slots ps + JOIN jobs j ON ps.job_id=j.id + JOIN printers p ON ps.printer_id=p.id + LEFT JOIN clients c ON j.client_id=c.id + WHERE ps.planned_start >= datetime('now', '-7 days') + ORDER BY ps.planned_start''').fetchall() + conn.close() + return jsonify([dict(s) for s in slots]) + +@app.route('/api/slots/new', methods=['POST']) +def api_new_slot(): + d = request.json + job_id = int(d['job_id']) + printer_id = int(d['printer_id']) + start_str = d['planned_start'] # ISO string + conn = get_db() + job = conn.execute('SELECT print_time_s FROM jobs WHERE id=?', (job_id,)).fetchone() + s = get_settings() + if not job: + conn.close() + return jsonify({'error': 'Job introuvable'}), 404 + cooldown_s = int(s.get('cooldown_minutes', 15)) * 60 + start_dt = datetime.fromisoformat(start_str) + end_dt = start_dt + timedelta(seconds=job['print_time_s'] + cooldown_s) + conn.execute('INSERT INTO print_slots(job_id,printer_id,planned_start,planned_end,status) VALUES(?,?,?,?,?)', + (job_id, printer_id, start_dt.isoformat(), end_dt.isoformat(), 'planned')) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/slots//move', methods=['POST']) +def api_move_slot(id): + d = request.json + start_str = d['planned_start'] + conn = get_db() + slot = conn.execute('SELECT ps.*, j.print_time_s FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?', (id,)).fetchone() + if not slot: + conn.close() + return jsonify({'error': 'Slot introuvable'}), 404 + s = get_settings() + cooldown_s = int(s.get('cooldown_minutes', 15)) * 60 + start_dt = datetime.fromisoformat(start_str) + end_dt = start_dt + timedelta(seconds=slot['print_time_s'] + cooldown_s) + conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?', + (start_dt.isoformat(), end_dt.isoformat(), id)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/slots//status', methods=['POST']) +def api_slot_status(id): + d = request.json + conn = get_db() + conn.execute('UPDATE print_slots SET status=?,failure_note=? WHERE id=?', + (d.get('status','planned'), d.get('note',''), id)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/slots//delete', methods=['POST']) +def api_delete_slot(id): + conn = get_db() + conn.execute('DELETE FROM print_slots WHERE id=?', (id,)) + conn.commit(); conn.close() + return jsonify({'ok': True}) + +@app.route('/api/suggest-slot', methods=['POST']) +def api_suggest_slot(): + d = request.json + print_time_s = int(d.get('print_time_s', 0)) + result = find_next_slot(print_time_s) + if result: + return jsonify(result) + return jsonify({'error': 'Aucun créneau disponible dans les 60 prochains jours'}), 404 + +@app.route('/api/capacity', methods=['POST']) +def api_capacity(): + d = request.json + print_time_s = int(d.get('print_time_s', 0)) + pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1))) + result = calc_capacity(print_time_s, pieces_per_plate) + return jsonify(result) + +@app.route('/api/availability') +def api_availability(): + """Returns unavailable periods for the next 4 weeks as JSON for vis.js background items.""" + schedule = get_schedule_dict() + today = date_cls.today() + blocks = get_blocks_dict(today, today + timedelta(days=28)) + result = [] + for offset in range(28): + d = today + timedelta(days=offset) + date_str = str(d) + block = next((b for b in blocks if b['date'] == date_str and b['printer_id'] is None), None) + mode = block['type'] if block else schedule[d.weekday()]['mode'] + if mode == 'off': + result.append({ + 'start': date_str + 'T00:00:00', + 'end': date_str + 'T23:59:59', + 'type': 'off' + }) + elif mode == 'tt': + sched = schedule[d.weekday()] + # Unavailable before tt_window_start + result.append({'start': date_str+'T00:00:00', 'end': date_str+'T'+sched['tt_window_start']+':00', 'type':'unavailable'}) + # Unavailable after tt_window_end + result.append({'start': date_str+'T'+sched['tt_window_end']+':00', 'end': date_str+'T23:59:59', 'type':'unavailable'}) + return jsonify(result) + +# ─── iCal Export ────────────────────────────────────────────────────────────── + +@app.route('/calendar.ics') +def calendar_ics(): + conn = get_db() + slots = conn.execute(''' + SELECT ps.*, j.name as job_name, j.print_time_s, + j.order_qty, j.pieces_per_plate, j.weight_g, + p.name as printer_name, c.name as client_name + FROM print_slots ps + JOIN jobs j ON ps.job_id=j.id + JOIN printers p ON ps.printer_id=p.id + LEFT JOIN clients c ON j.client_id=c.id + WHERE ps.status NOT IN ('cancelled') + ORDER BY ps.planned_start''').fetchall() + conn.close() + + lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//3D Pricing//Planning//FR', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:Planning Impression 3D', + 'X-WR-TIMEZONE:Europe/Paris', + ] + for s in slots: + start = s['planned_start'].replace('-','').replace(':','').replace(' ','T') + if '.' in start: + start = start.split('.')[0] + end = s['planned_end'].replace('-','').replace(':','').replace(' ','T') + if '.' in end: + end = end.split('.')[0] + if 'T' not in start: + start += 'T000000' + if 'T' not in end: + end += 'T000000' + client_part = f" — {s['client_name']}" if s['client_name'] else '' + lines += [ + 'BEGIN:VEVENT', + f"DTSTART:{start}", + f"DTEND:{end}", + f"SUMMARY:{s['job_name']}{client_part} [{s['printer_name']}]", + f"DESCRIPTION:Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau | Statut: {s['status']}", + f"UID:slot-{s['id']}@3d-pricing", + f"STATUS:{'CONFIRMED' if s['status']=='done' else 'TENTATIVE'}", + 'END:VEVENT', + ] + lines.append('END:VCALENDAR') + ical = '\r\n'.join(lines) + return Response(ical, mimetype='text/calendar', + headers={'Content-Disposition': 'attachment; filename=planning_3d.ics'}) + # ─── Paramètres ─────────────────────────────────────────────────────────────── @app.route('/settings', methods=['GET','POST']) @@ -831,19 +1326,23 @@ def export_csv(): 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.pieces_per_plate, j.order_qty, j.design_multiplier, j.gross_margin_pct, j.discount_pct, j.cout_fixe, j.handling_cost, j.design_cost, j.total_marge, - j.margin_amount, j.cotisations_amount, j.vfl_amount, j.tva_amount, - j.final_price + j.margin_amount, j.cotisations_amount, j.vfl_amount, + j.other_taxes_amount, j.tva_amount, + j.price_per_piece, 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(%)', + 'Poids(g)','Temps(s)','Pièces/plateau','Qté commande', + 'Coeff Design','Marge(%)','Remise(%)', 'Cout fixe','Manutention','Design','Total marge', - 'Marge brute','Cotisations','VFL','TVA','Prix Final']) + 'Marge brute','Cotisations','VFL','Autres taxes','TVA', + 'Prix/pièce','Prix Final']) for j in jobs: w.writerow(list(j)) return Response(out.getvalue(), mimetype='text/csv', diff --git a/templates/base.html b/templates/base.html index ce436e7..49dedee 100644 --- a/templates/base.html +++ b/templates/base.html @@ -87,6 +87,9 @@ Templates + + Planning + Statistiques diff --git a/templates/calendar_blocks.html b/templates/calendar_blocks.html new file mode 100644 index 0000000..da40b4a --- /dev/null +++ b/templates/calendar_blocks.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} +{% block title %}Indisponibilités{% endblock %} +{% block content %} + + +
+
+
+
Ajouter une exception
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
Exceptions récentes / à venir
+
+ {% if blocks %} + + + + + + {% for b in blocks %} + + + + + + + + {% endfor %} + +
DateTypeMachineNote
{{ b.date }} + {% if b.type == 'off' %}Off + {% elif b.type == 'tt' %}TT + {% elif b.type == 'onsite' %}Sur site + {% else %}{{ b.type }}{% endif %} + {{ b.printer_name or 'Toutes' }}{{ b.notes or '—' }} +
+ +
+
+ {% else %} +
+ +

Aucune exception définie.

+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/new_job.html b/templates/new_job.html index 962647f..532164c 100644 --- a/templates/new_job.html +++ b/templates/new_job.html @@ -71,7 +71,7 @@
-
+
Temps total Bambu
+
+
+ + +
Combien de pièces tiennent sur un plateau.
+
+
+ + +
Nombre total de pièces à produire.
+
+
@@ -338,7 +352,7 @@ document.getElementById('materialSelect').addEventListener('change', function() }); // ── Autres champs → recalc ──────────────────────────────────────────────────── -['weight_g','hours','minutes','discount_pct'].forEach(id => { +['weight_g','hours','minutes','discount_pct','pieces_per_plate','order_qty'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('input', recalc); }); @@ -366,6 +380,8 @@ function recalc() { machine_profile_id: document.getElementById('machineProfileSelect').value || null, handling_profile_id: document.getElementById('handlingProfileSelect').value || null, material_profile_id: document.getElementById('materialProfileSelect').value || null, + pieces_per_plate: parseInt(document.getElementById('pieces_per_plate').value) || 1, + order_qty: parseInt(document.getElementById('order_qty').value) || 1, }; fetch('/api/calculate', { @@ -407,7 +423,47 @@ function renderBreakdown(d, body) { const disRow = body.discount_pct > 0 ? `
Remise (${body.discount_pct}%)-${fmt2(d.prix_ttc * body.discount_pct / 100)}
` : ''; - document.getElementById('breakdown').innerHTML = ` + // Multi-piece header + const qty = body.order_qty || 1; + const ppp = body.pieces_per_plate || 1; + const nbPlateaux = Math.ceil(qty / ppp); + const lastPlate = qty % ppp; + const priceOrder = d.price_order || (d.final_price * qty); + let plateauAlert = ''; + if (lastPlate !== 0) { + plateauAlert = `
+ + Dernier plateau incomplet : ${lastPlate}/${ppp} pièces. + Vous pourriez imprimer jusqu'à ${nbPlateaux * ppp} pièces pour le même coût fixe. +
`; + } + + // Prix header (1 ou 2 colonnes) + const multiPriceHeader = qty > 1 + ? `
+
+
Prix / pièce
+
${fmt2(d.final_price)}
+
+
+
Prix commande (×${qty})
+
${fmt2(priceOrder)}
+
+
` + : ''; + + // Capacité + const cap = d.capacity || {}; + const totalMin = d.total_print_min || 0; + const capBlock = ` +
Capacite de production
+
Nb plateaux necessaires${nbPlateaux} plateau${nbPlateaux>1?'x':''}
+
Duree totale (impression + cooldown)${Math.floor(totalMin/60)}h${String(Math.round(totalMin%60)).padStart(2,'0')}min
+
Pieces / jour (${cap.effective_printers||5} machines)${cap.pieces_per_day||'—'}
+
Pieces / semaine${cap.pieces_per_week||'—'}
+
Pieces / mois (30j)${cap.pieces_per_month||'—'}
`; + + document.getElementById('breakdown').innerHTML = plateauAlert + multiPriceHeader + `
Cout fixe
Matiere ${matName}${fmt(d.material_cost)}
Marge matiere (${d._material_margin_pct}%)${fmt(d.material_margin)}
@@ -430,7 +486,8 @@ function renderBreakdown(d, body) {
Prix HT${fmt2(d.prix_ht_net)}
${tvaLabel}${d._tva_pct > 0 ? fmt2(d.tva_amount) : ''}
${disRow} -
PRIX FINAL TTC${fmt2(d.final_price)}
`; +
PRIX FINAL TTC${fmt2(d.final_price)}
+ ` + capBlock; } // Init diff --git a/templates/planning.html b/templates/planning.html new file mode 100644 index 0000000..c318c33 --- /dev/null +++ b/templates/planning.html @@ -0,0 +1,378 @@ +{% extends 'base.html' %} +{% block title %}Planning{% endblock %} + +{% block content %} + + +
+ +
+
+
+ Gantt — 14 prochains jours +
+ + + +
+
+
+
+
+
+
+ + +
+
+
+ À planifier + {{ unscheduled|length }} +
+
+ {% if unscheduled %} +
+ {% for j in unscheduled %} +
+
+
+
{{ j.name }}
+
+ {{ j.print_time_s | fmt_time }} + {% if j.order_qty > 1 %} · ×{{ j.order_qty }} pièces{% endif %} + {% if j.client_name %} · {{ j.client_name }}{% endif %} +
+
+
+ + +
+
+
+ {% endfor %} +
+ {% else %} +
+ +

Toutes les commandes sont planifiées.

+
+ {% endif %} +
+
+
+ + +
+
+
Créneaux planifiés
+
+
Chargement…
+
+
+
+
+ + + + + + +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/templates/printers.html b/templates/printers.html new file mode 100644 index 0000000..dfee709 --- /dev/null +++ b/templates/printers.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} +{% block title %}Imprimantes{% endblock %} +{% block content %} + + +
+
+
+
Parc machines
+
+ + + + + + {% for p in printers %} + + + + + + + {% endfor %} + +
NomStatutNotes
{{ p.name }} + {% if p.is_active %} + Active + {% else %} + Inactive + {% endif %} + {{ p.notes or '—' }} +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
Ajouter une imprimante
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + Max 5 imprimantes simultanées (contrainte électrique). Configurez ce maximum dans Paramètres. +
L'imprimante #6 est inactive par défaut. +
+
+
+{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index 80a83de..f3c957a 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -109,6 +109,30 @@ +
+
Planning & capacite
+
+
+ + +
Temps de refroidissement + dechargement entre deux lancements sur la meme machine.
+
+
+ + +
Surconsommation estimee due aux echecs d'impression (alerte restock).
+
+
+ + +
5 — contrainte electrique (6 × A1 = trop de W au-dela de 5).
+
+
+
+
Fiscal
diff --git a/templates/working_schedule.html b/templates/working_schedule.html new file mode 100644 index 0000000..b89ad1e --- /dev/null +++ b/templates/working_schedule.html @@ -0,0 +1,97 @@ +{% extends 'base.html' %} +{% block title %}Horaires{% endblock %} +{% block content %} + + +
+
+
+
+
Planning hebdomadaire par défaut
+
+ + + + + + + + + + + {% set day_names = ['Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi','Dimanche'] %} + {% for s in schedule %} + + + + + + + {% endfor %} + +
JourModeFenêtre TT (début → fin)Créneaux sur site
{{ day_names[s.day_of_week] }} + + +
+ + + +
+
+
+ + + + +
+
+
+
+
+ +
+
+
+
+
+
Légende
+
+

Télétravail
Fenêtre continue — les impressions se lancent en séquence de l'heure de début à l'heure de fin.

+

Sur site
2 créneaux fixes — matin (avant de partir) et soir (en rentrant).

+

Off
Aucune impression ce jour-là par défaut.

+
+

Ces horaires sont remplacés jour par jour via les Indisponibilités.

+
+
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %}