feat: max simultaneous printers enforced + capacity bands on Gantt
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 10:39:50 +02:00
parent 00518c3f3c
commit 10fdeb3ebc
5 changed files with 269 additions and 26 deletions
+78 -2
View File
@@ -1304,8 +1304,10 @@ def planning():
).fetchall()
schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall()
conn.close()
settings = get_settings()
return render_template('planning.html', printers=printers,
unscheduled=unscheduled, blocks=blocks, schedule=schedule)
unscheduled=unscheduled, blocks=blocks, schedule=schedule,
settings=settings)
@app.route('/printers', methods=['GET','POST'])
def printers_page():
@@ -1620,17 +1622,71 @@ def api_slots():
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,
j.plate_name,
p.name as printer_name,
c.name as client_name
c.name as client_name,
m.name as material_name, m.type as material_type, m.color as material_color
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
LEFT JOIN materials m ON j.material_id=m.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])
def _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=None):
"""Vérifie si ajouter ce créneau dépasserait max_printers simultanés.
Retourne (True, peak_count) si limite atteinte, (False, peak_count) sinon.
Méthode : on évalue le nombre de slots actifs à chaque 'event point'
(toutes les heures de début des créneaux qui chevauchent la fenêtre).
"""
q = """SELECT planned_start, planned_end FROM print_slots
WHERE status NOT IN ('cancelled','failed','done')
AND planned_start < ? AND planned_end > ?"""
params = [end_dt.isoformat(), start_dt.isoformat()]
if exclude_id:
q += ' AND id != ?'
params.append(exclude_id)
overlapping = conn.execute(q, params).fetchall()
if len(overlapping) < max_printers:
return False, len(overlapping) # pas assez de chevauchements pour atteindre la limite
# Points à tester : le début du créneau proposé + début de chaque slot existant dans la fenêtre
event_times = [start_dt]
for sl in overlapping:
t = datetime.fromisoformat(sl['planned_start'])
if start_dt <= t < end_dt:
event_times.append(t)
peak = 0
for t in event_times:
count = sum(
1 for sl in overlapping
if datetime.fromisoformat(sl['planned_start']) <= t
< datetime.fromisoformat(sl['planned_end'])
)
if count > peak:
peak = count
if count >= max_printers:
return True, count
return False, peak
def _check_overlap(conn, printer_id, start_dt, end_dt, exclude_id=None):
"""Retourne le slot en conflit s'il existe, sinon None."""
q = """SELECT ps.id, j.name as job_name, ps.planned_start, ps.planned_end
FROM print_slots ps JOIN jobs j ON ps.job_id=j.id
WHERE ps.printer_id=?
AND ps.status NOT IN ('cancelled','failed','done')
AND ps.planned_start < ? AND ps.planned_end > ?"""
params = [printer_id, end_dt.isoformat(), start_dt.isoformat()]
if exclude_id:
q += ' AND ps.id != ?'
params.append(exclude_id)
return conn.execute(q, params).fetchone()
@app.route('/api/slots/new', methods=['POST'])
def api_new_slot():
d = request.json
@@ -1646,6 +1702,16 @@ def api_new_slot():
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)
conflict = _check_overlap(conn, printer_id, start_dt, end_dt)
if conflict:
conn.close()
end_conf = conflict['planned_end'][:16].replace('T', ' ')
return jsonify({'error': f"Chevauchement avec « {conflict['job_name']} » (fin prévue {end_conf}). Choisissez un autre horaire ou une autre imprimante."}), 409
max_printers = int(s.get('max_simultaneous_printers', 5))
over, peak = _check_capacity(conn, start_dt, end_dt, max_printers)
if over:
conn.close()
return jsonify({'error': f"Capacité maximale atteinte : {peak} impression(s) déjà en cours sur ce créneau (limite : {max_printers} simultanées). Décalez l'horaire ou attendez qu'une imprimante se libère."}), 409
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()
@@ -1664,6 +1730,16 @@ def api_move_slot(id):
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)
conflict = _check_overlap(conn, slot['printer_id'], start_dt, end_dt, exclude_id=id)
if conflict:
conn.close()
end_conf = conflict['planned_end'][:16].replace('T', ' ')
return jsonify({'error': f"Chevauchement avec « {conflict['job_name']} » (fin prévue {end_conf})."}), 409
max_printers = int(s.get('max_simultaneous_printers', 5))
over, peak = _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=id)
if over:
conn.close()
return jsonify({'error': f"Capacité maximale atteinte : {peak} impression(s) en cours sur ce créneau (limite : {max_printers} simultanées)."}), 409
conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?',
(start_dt.isoformat(), end_dt.isoformat(), id))
conn.commit(); conn.close()