Planning complet : multi-pièces, capacité, Gantt, iCal, horaires, imprimantes
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-bookmark-star"></i> Templates
|
||||
</a>
|
||||
<a href="{{ url_for('planning') }}" class="{% if request.endpoint in ['planning','printers_page','working_schedule','calendar_blocks'] %}active{% endif %}">
|
||||
<i class="bi bi-calendar3"></i> Planning
|
||||
</a>
|
||||
<div class="nav-section">Analyse</div>
|
||||
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}">
|
||||
<i class="bi bi-bar-chart-line"></i> Statistiques
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Indisponibilités{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-calendar-x me-2 text-secondary"></i>Indisponibilités</h1>
|
||||
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Planning
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">Ajouter une exception</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Date</label>
|
||||
<input type="date" name="date" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Type</label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="off">Off / Jour chômé</option>
|
||||
<option value="tt">Télétravail (remplace onsite)</option>
|
||||
<option value="onsite">Sur site (remplace TT)</option>
|
||||
<option value="maintenance">Maintenance machine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Imprimante concernée</label>
|
||||
<select name="printer_id" class="form-select">
|
||||
<option value="">Toutes les imprimantes</option>
|
||||
{% for p in printers %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Note</label>
|
||||
<input type="text" name="notes" class="form-control" placeholder="ex: Vacances août, maintenance buse…">
|
||||
</div>
|
||||
<button type="submit" class="btn fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">Exceptions récentes / à venir</div>
|
||||
<div class="card-body p-0">
|
||||
{% if blocks %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Type</th><th>Machine</th><th>Note</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in blocks %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ b.date }}</td>
|
||||
<td>
|
||||
{% if b.type == 'off' %}<span class="badge bg-danger">Off</span>
|
||||
{% elif b.type == 'tt' %}<span class="badge bg-primary">TT</span>
|
||||
{% elif b.type == 'onsite' %}<span class="badge bg-warning text-dark">Sur site</span>
|
||||
{% else %}<span class="badge bg-secondary">{{ b.type }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ b.printer_name or 'Toutes' }}</td>
|
||||
<td class="text-muted small">{{ b.notes or '—' }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('delete_calendar_block', id=b.id) }}">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-check fs-2"></i>
|
||||
<p class="mt-2 small">Aucune exception définie.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+61
-4
@@ -71,7 +71,7 @@
|
||||
<div id="stockInfo" class="form-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Poids filament (g)</label>
|
||||
<input type="number" name="weight_g" id="weight_g" class="form-control"
|
||||
@@ -88,6 +88,20 @@
|
||||
<div class="form-text">Temps total Bambu</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Pièces par plateau</label>
|
||||
<input type="number" name="pieces_per_plate" id="pieces_per_plate" class="form-control"
|
||||
min="1" step="1" value="1">
|
||||
<div class="form-text">Combien de pièces tiennent sur un plateau.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Quantité commandée</label>
|
||||
<input type="number" name="order_qty" id="order_qty" class="form-control"
|
||||
min="1" step="1" value="1">
|
||||
<div class="form-text">Nombre total de pièces à produire.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
? `<div class="breakdown-row text-danger"><span class="ps-2">Remise (${body.discount_pct}%)</span><span>-${fmt2(d.prix_ttc * body.discount_pct / 100)}</span></div>` : '';
|
||||
|
||||
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 = `<div class="alert alert-warning py-2 mb-2 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Dernier plateau incomplet : <strong>${lastPlate}/${ppp} pièces</strong>.
|
||||
Vous pourriez imprimer jusqu'à <strong>${nbPlateaux * ppp}</strong> pièces pour le même coût fixe.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Prix header (1 ou 2 colonnes)
|
||||
const multiPriceHeader = qty > 1
|
||||
? `<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="text-center flex-fill border-end">
|
||||
<div class="small text-muted">Prix / pièce</div>
|
||||
<div class="fw-bold fs-5" style="color:#ff6b35">${fmt2(d.final_price)}</div>
|
||||
</div>
|
||||
<div class="text-center flex-fill">
|
||||
<div class="small text-muted">Prix commande (×${qty})</div>
|
||||
<div class="fw-bold fs-5" style="color:#ff6b35">${fmt2(priceOrder)}</div>
|
||||
</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
// Capacité
|
||||
const cap = d.capacity || {};
|
||||
const totalMin = d.total_print_min || 0;
|
||||
const capBlock = `
|
||||
<div class="breakdown-row section-header mt-1"><span>Capacite de production</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Nb plateaux necessaires</span><span><strong>${nbPlateaux}</strong> plateau${nbPlateaux>1?'x':''}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Duree totale (impression + cooldown)</span><span>${Math.floor(totalMin/60)}h${String(Math.round(totalMin%60)).padStart(2,'0')}min</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / jour (${cap.effective_printers||5} machines)</span><span>${cap.pieces_per_day||'—'}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / semaine</span><span>${cap.pieces_per_week||'—'}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / mois (30j)</span><span>${cap.pieces_per_month||'—'}</span></div>`;
|
||||
|
||||
document.getElementById('breakdown').innerHTML = plateauAlert + multiPriceHeader + `
|
||||
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Matiere <small class="text-secondary">${matName}</small></span><span>${fmt(d.material_cost)}</span></div>
|
||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Marge matiere (${d._material_margin_pct}%)</span><span>${fmt(d.material_margin)}</span></div>
|
||||
@@ -430,7 +486,8 @@ function renderBreakdown(d, body) {
|
||||
<div class="breakdown-row total" style="border-top:none"><span>Prix HT</span><span class="final-price">${fmt2(d.prix_ht_net)}</span></div>
|
||||
<div class="breakdown-row ${tvaClass}"><span class="ps-2">${tvaLabel}</span><span>${d._tva_pct > 0 ? fmt2(d.tva_amount) : ''}</span></div>
|
||||
${disRow}
|
||||
<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(d.final_price)}</span></div>`;
|
||||
<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(d.final_price)}</span></div>
|
||||
` + capBlock;
|
||||
}
|
||||
|
||||
// Init
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Planning{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h1><i class="bi bi-calendar3 me-2" style="color:#ff6b35"></i>Planning d'impression</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('working_schedule') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-clock me-1"></i>Horaires
|
||||
</a>
|
||||
<a href="{{ url_for('calendar_blocks') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-calendar-x me-1"></i>Indispos
|
||||
</a>
|
||||
<a href="{{ url_for('printers_page') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-printer me-1"></i>Imprimantes
|
||||
</a>
|
||||
<a href="/calendar.ics" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-calendar-check me-1"></i>Export iCal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Timeline -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-bar-chart-gantt me-2"></i>Gantt — 14 prochains jours</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="shiftTimeline(-7)"><i class="bi bi-chevron-left"></i></button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="timeline.moveTo(new Date())">Aujourd'hui</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="shiftTimeline(7)"><i class="bi bi-chevron-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="timeline" style="height:320px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commandes non planifiées -->
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-inbox me-2"></i>À planifier
|
||||
<span class="badge bg-secondary ms-1">{{ unscheduled|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if unscheduled %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for j in unscheduled %}
|
||||
<div class="list-group-item py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold small">{{ j.name }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem">
|
||||
{{ 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 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-success py-0"
|
||||
onclick="suggestSlot({{ j.id }}, {{ j.print_time_s }}, '{{ j.name|replace("'","\\'")|truncate(30,true,"…") }}')">
|
||||
<i class="bi bi-magic"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary py-0"
|
||||
onclick="openScheduleModal({{ j.id }}, {{ j.print_time_s }}, '{{ j.name|replace("'","\\'")|truncate(30,true,"…") }}')">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4 small">
|
||||
<i class="bi bi-check2-circle fs-3"></i>
|
||||
<p class="mt-2">Toutes les commandes sont planifiées.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prochains créneaux -->
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-list-ul me-2"></i>Créneaux planifiés</div>
|
||||
<div class="card-body p-0" id="slotsList">
|
||||
<div class="text-center text-muted py-3 small">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal planification -->
|
||||
<div class="modal fade" id="scheduleModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-calendar-plus me-2"></i>Planifier l'impression</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modalJobId">
|
||||
<input type="hidden" id="modalPrintTime">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Commande</label>
|
||||
<div id="modalJobName" class="form-control-plaintext fw-semibold text-primary"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Imprimante</label>
|
||||
<select id="modalPrinter" class="form-select">
|
||||
{% for p in printers %}
|
||||
{% if p.is_active %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Date et heure de lancement</label>
|
||||
<input type="datetime-local" id="modalStart" class="form-control">
|
||||
</div>
|
||||
<div id="modalSuggestion" class="alert alert-success py-2 small d-none">
|
||||
<i class="bi bi-magic me-1"></i><span id="modalSuggestionText"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn fw-bold" style="background:#ff6b35;color:#fff" onclick="saveSlot()">
|
||||
<i class="bi bi-save me-1"></i>Planifier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal statut slot -->
|
||||
<div class="modal fade" id="slotModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mettre à jour le créneau</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="slotModalId">
|
||||
<p id="slotModalInfo" class="text-muted small"></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Statut</label>
|
||||
<select id="slotModalStatus" class="form-select">
|
||||
<option value="planned">Planifié</option>
|
||||
<option value="printing">En cours</option>
|
||||
<option value="done">Terminé ✓</option>
|
||||
<option value="failed">Échec ✗</option>
|
||||
<option value="cancelled">Annulé</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3" id="slotModalNoteGroup">
|
||||
<label class="form-label">Note (optionnel)</label>
|
||||
<input type="text" id="slotModalNote" class="form-control" placeholder="Ex: couche 45 — sous-extrusion">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between">
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteSlot()">
|
||||
<i class="bi bi-trash"></i> Supprimer
|
||||
</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn btn-success ms-1" onclick="updateSlotStatus()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>
|
||||
<script>
|
||||
const STATUS_COLORS = {
|
||||
planned: '#3b82f6',
|
||||
printing: '#f59e0b',
|
||||
done: '#22c55e',
|
||||
failed: '#ef4444',
|
||||
cancelled: '#9ca3af',
|
||||
};
|
||||
const STATUS_LABELS = {
|
||||
planned: 'Planifié', printing: 'En cours', done: 'Terminé', failed: 'Échec', cancelled: 'Annulé'
|
||||
};
|
||||
|
||||
let timeline, items, groups;
|
||||
|
||||
async function initTimeline() {
|
||||
const [slotsRes, availRes] = await Promise.all([
|
||||
fetch('/api/slots').then(r => r.json()),
|
||||
fetch('/api/availability').then(r => r.json()),
|
||||
]);
|
||||
|
||||
// Printers as groups
|
||||
const printerData = {{ printers | tojson }};
|
||||
groups = new vis.DataSet(printerData.map(p => ({
|
||||
id: p.id,
|
||||
content: `<span class="small fw-semibold">${p.name}</span>`,
|
||||
className: p.is_active ? '' : 'text-muted'
|
||||
})));
|
||||
|
||||
// Background items (unavailable periods)
|
||||
const bgItems = availRes.map((a, i) => ({
|
||||
id: 'bg_' + i,
|
||||
start: a.start,
|
||||
end: a.end,
|
||||
type: 'background',
|
||||
className: a.type === 'off' ? 'bg-danger bg-opacity-10' : 'bg-secondary bg-opacity-10',
|
||||
}));
|
||||
|
||||
// Print slots
|
||||
const slotItems = slotsRes.map(s => ({
|
||||
id: s.id,
|
||||
group: s.printer_id,
|
||||
start: s.planned_start,
|
||||
end: s.planned_end,
|
||||
content: `<span style="font-size:.7rem">${s.job_name}</span>`,
|
||||
title: `${s.job_name}${s.client_name ? ' — ' + s.client_name : ''}<br>${STATUS_LABELS[s.status] || s.status}`,
|
||||
style: `background:${STATUS_COLORS[s.status] || '#3b82f6'};border-color:${STATUS_COLORS[s.status] || '#3b82f6'};color:#fff;border-radius:4px`,
|
||||
}));
|
||||
|
||||
items = new vis.DataSet([...bgItems, ...slotItems]);
|
||||
|
||||
const now = new Date();
|
||||
timeline = new vis.Timeline(
|
||||
document.getElementById('timeline'),
|
||||
items, groups,
|
||||
{
|
||||
start: now,
|
||||
end: new Date(now.getTime() + 14 * 86400000),
|
||||
groupOrder: 'id',
|
||||
selectable: true,
|
||||
zoomMin: 3600000,
|
||||
zoomMax: 60 * 86400000,
|
||||
locale: 'fr',
|
||||
}
|
||||
);
|
||||
|
||||
timeline.on('select', function(props) {
|
||||
if (!props.items.length) return;
|
||||
const id = props.items[0];
|
||||
if (typeof id === 'string' && id.startsWith('bg_')) return;
|
||||
const slot = slotsRes.find(s => s.id === id);
|
||||
if (!slot) return;
|
||||
openSlotModal(slot);
|
||||
});
|
||||
|
||||
renderSlotsList(slotsRes);
|
||||
}
|
||||
|
||||
function shiftTimeline(days) {
|
||||
const w = timeline.getWindow();
|
||||
const ms = days * 86400000;
|
||||
timeline.setWindow(new Date(w.start.getTime() + ms), new Date(w.end.getTime() + ms));
|
||||
}
|
||||
|
||||
function renderSlotsList(slots) {
|
||||
const el = document.getElementById('slotsList');
|
||||
if (!slots.length) {
|
||||
el.innerHTML = '<div class="text-center text-muted py-4 small"><i class="bi bi-calendar fs-3"></i><p class="mt-2">Aucun créneau planifié.</p></div>';
|
||||
return;
|
||||
}
|
||||
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 15);
|
||||
el.innerHTML = `<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<th class="small">Début</th><th class="small">Job</th>
|
||||
<th class="small">Machine</th><th class="small">Statut</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${upcoming.map(s => `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'"')})">
|
||||
<td class="small">${formatDt(s.planned_start)}</td>
|
||||
<td class="small fw-semibold">${s.job_name}${s.client_name ? '<br><span class=\'text-muted fw-normal\'>' + s.client_name + '</span>' : ''}</td>
|
||||
<td class="small">${s.printer_name}</td>
|
||||
<td><span class="badge" style="background:${STATUS_COLORS[s.status]}">${STATUS_LABELS[s.status]||s.status}</span></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function formatDt(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'}) + ' ' +
|
||||
d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
function openScheduleModal(jobId, printTime, jobName) {
|
||||
document.getElementById('modalJobId').value = jobId;
|
||||
document.getElementById('modalPrintTime').value = printTime;
|
||||
document.getElementById('modalJobName').textContent = jobName;
|
||||
document.getElementById('modalSuggestion').classList.add('d-none');
|
||||
// Default: now rounded to next hour
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
now.setHours(now.getHours() + 1);
|
||||
document.getElementById('modalStart').value = now.toISOString().slice(0,16);
|
||||
new bootstrap.Modal(document.getElementById('scheduleModal')).show();
|
||||
}
|
||||
|
||||
async function suggestSlot(jobId, printTime, jobName) {
|
||||
openScheduleModal(jobId, printTime, jobName);
|
||||
const res = await fetch('/api/suggest-slot', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({print_time_s: printTime})
|
||||
}).then(r => r.json());
|
||||
if (res.planned_start) {
|
||||
document.getElementById('modalStart').value = res.planned_start.slice(0,16);
|
||||
// Find the printer option
|
||||
const sel = document.getElementById('modalPrinter');
|
||||
for (let opt of sel.options) {
|
||||
if (parseInt(opt.value) === res.printer_id) { sel.value = opt.value; break; }
|
||||
}
|
||||
document.getElementById('modalSuggestionText').textContent =
|
||||
`Suggestion : ${res.printer_name} — ${formatDt(res.planned_start)}`;
|
||||
document.getElementById('modalSuggestion').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSlot() {
|
||||
const jobId = parseInt(document.getElementById('modalJobId').value);
|
||||
const printer = parseInt(document.getElementById('modalPrinter').value);
|
||||
const start = document.getElementById('modalStart').value;
|
||||
const res = await fetch('/api/slots/new', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start})
|
||||
}).then(r => r.json());
|
||||
if (res.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Erreur : ' + (res.error || 'inconnu'));
|
||||
}
|
||||
}
|
||||
|
||||
let currentSlot = null;
|
||||
function openSlotModal(slot) {
|
||||
currentSlot = slot;
|
||||
document.getElementById('slotModalId').value = slot.id;
|
||||
document.getElementById('slotModalInfo').textContent =
|
||||
`${slot.job_name} · ${slot.printer_name} · ${formatDt(slot.planned_start)}`;
|
||||
document.getElementById('slotModalStatus').value = slot.status || 'planned';
|
||||
document.getElementById('slotModalNote').value = slot.failure_note || '';
|
||||
new bootstrap.Modal(document.getElementById('slotModal')).show();
|
||||
}
|
||||
|
||||
async function updateSlotStatus() {
|
||||
const id = document.getElementById('slotModalId').value;
|
||||
const status = document.getElementById('slotModalStatus').value;
|
||||
const note = document.getElementById('slotModalNote').value;
|
||||
await fetch(`/api/slots/${id}/status`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({status, note})
|
||||
});
|
||||
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function deleteSlot() {
|
||||
if (!confirm('Supprimer ce créneau ?')) return;
|
||||
const id = document.getElementById('slotModalId').value;
|
||||
await fetch(`/api/slots/${id}/delete`, {method:'POST'});
|
||||
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
initTimeline();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Imprimantes{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-printer me-2 text-secondary"></i>Imprimantes</h1>
|
||||
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Planning
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">Parc machines</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Nom</th><th>Statut</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ p.name }}</td>
|
||||
<td>
|
||||
{% if p.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ p.notes or '—' }}</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<form method="POST" action="{{ url_for('toggle_printer', id=p.id) }}">
|
||||
<button class="btn btn-sm {% if p.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %} py-0">
|
||||
{% if p.is_active %}<i class="bi bi-pause"></i>{% else %}<i class="bi bi-play"></i>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('delete_printer', id=p.id) }}"
|
||||
onsubmit="return confirm('Supprimer {{ p.name }} ?')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">Ajouter une imprimante</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nom</label>
|
||||
<input type="text" name="name" class="form-control" required placeholder="ex: A1 #7, X1C #1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<input type="text" name="notes" class="form-control" placeholder="couleur, emplacement…">
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isActive" checked>
|
||||
<label class="form-check-label" for="isActive">Active dès l'ajout</label>
|
||||
</div>
|
||||
<button type="submit" class="btn fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Max <strong>5 imprimantes simultanées</strong> (contrainte électrique). Configurez ce maximum dans <a href="{{ url_for('settings') }}">Paramètres</a>.
|
||||
<br>L'imprimante #6 est inactive par défaut.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -109,6 +109,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3"><i class="bi bi-calendar3 me-2"></i>Planning & capacite</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Cooldown entre impressions (minutes)</label>
|
||||
<input type="number" name="cooldown_minutes" class="form-control"
|
||||
step="1" min="0" value="{{ settings.get('cooldown_minutes', 15) }}">
|
||||
<div class="form-text">Temps de refroidissement + dechargement entre deux lancements sur la meme machine.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Buffer echec filament (%)</label>
|
||||
<input type="number" name="failure_buffer_pct" class="form-control"
|
||||
step="1" min="0" max="50" value="{{ settings.get('failure_buffer_pct', 10) }}">
|
||||
<div class="form-text">Surconsommation estimee due aux echecs d'impression (alerte restock).</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nb max imprimantes simultanees</label>
|
||||
<input type="number" name="max_simultaneous_printers" class="form-control"
|
||||
step="1" min="1" max="10" value="{{ settings.get('max_simultaneous_printers', 5) }}">
|
||||
<div class="form-text"><strong>5</strong> — contrainte electrique (6 × A1 = trop de W au-dela de 5).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-percent me-2"></i>Fiscal</div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Horaires{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-clock me-2 text-secondary"></i>Horaires d'impression</h1>
|
||||
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Planning
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<form method="POST">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">Planning hebdomadaire par défaut</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:120px">Jour</th>
|
||||
<th style="width:150px">Mode</th>
|
||||
<th>Fenêtre TT (début → fin)</th>
|
||||
<th>Créneaux sur site</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set day_names = ['Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi','Dimanche'] %}
|
||||
{% for s in schedule %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ day_names[s.day_of_week] }}</td>
|
||||
<td>
|
||||
<select name="mode_{{ s.day_of_week }}" class="form-select form-select-sm mode-select"
|
||||
data-dow="{{ s.day_of_week }}" onchange="toggleRow(this)">
|
||||
<option value="tt" {% if s.mode=='tt' %}selected{% endif %}>Télétravail</option>
|
||||
<option value="onsite" {% if s.mode=='onsite' %}selected{% endif %}>Sur site</option>
|
||||
<option value="off" {% if s.mode=='off' %}selected{% endif %}>Off</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="tt-fields-{{ s.day_of_week }}" {% if s.mode != 'tt' %}style="opacity:.3;pointer-events:none"{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="time" name="tt_start_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
|
||||
value="{{ s.tt_window_start }}">
|
||||
<span class="text-muted">→</span>
|
||||
<input type="time" name="tt_end_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
|
||||
value="{{ s.tt_window_end }}">
|
||||
</div>
|
||||
</td>
|
||||
<td class="onsite-fields-{{ s.day_of_week }}" {% if s.mode != 'onsite' %}style="opacity:.3;pointer-events:none"{% endif %}>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="time" name="onsite_slot1_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
|
||||
value="{{ s.onsite_slot_1 }}">
|
||||
<span class="text-muted">+</span>
|
||||
<input type="time" name="onsite_slot2_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
|
||||
value="{{ s.onsite_slot_2 }}">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn px-4 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-2"></i>Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">Légende</div>
|
||||
<div class="card-body small">
|
||||
<p><span class="badge bg-primary">Télétravail</span><br>Fenêtre continue — les impressions se lancent en séquence de l'heure de début à l'heure de fin.</p>
|
||||
<p><span class="badge bg-warning text-dark">Sur site</span><br>2 créneaux fixes — matin (avant de partir) et soir (en rentrant).</p>
|
||||
<p><span class="badge bg-secondary">Off</span><br>Aucune impression ce jour-là par défaut.</p>
|
||||
<hr>
|
||||
<p class="text-muted">Ces horaires sont remplacés jour par jour via les <a href="{{ url_for('calendar_blocks') }}">Indisponibilités</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleRow(sel) {
|
||||
const dow = sel.dataset.dow;
|
||||
const mode = sel.value;
|
||||
const ttEl = document.querySelector('.tt-fields-' + dow);
|
||||
const onsiteEl = document.querySelector('.onsite-fields-' + dow);
|
||||
ttEl.style.opacity = mode === 'tt' ? '1' : '0.3';
|
||||
ttEl.style.pointerEvents = mode === 'tt' ? '' : 'none';
|
||||
onsiteEl.style.opacity = mode === 'onsite' ? '1' : '0.3';
|
||||
onsiteEl.style.pointerEvents = mode === 'onsite' ? '' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user