Planning complet : multi-pièces, capacité, Gantt, iCal, horaires, imprimantes

This commit is contained in:
Jo
2026-07-06 16:15:14 +02:00
parent dbbbc92d91
commit bd6f244260
8 changed files with 1308 additions and 76 deletions
+571 -72
View File
@@ -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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>')
@@ -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/<int:id>/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/<int:id>/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/<int:id>/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',