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()
+66
View File
@@ -105,10 +105,49 @@
.stock-ok { color: #22c55e; }
.stock-low { color: #f59e0b; }
.stock-empty { color: #ef4444; }
/* ── Mobile responsive ───────────────────────────────────────────────── */
@media (max-width: 767.98px) {
#sidebar { display: none !important; }
#main { margin-left: 0 !important; padding: 1rem 0.75rem; }
/* Empêcher le zoom auto iOS sur les inputs */
input, select, textarea { font-size: max(1rem, 16px) !important; }
.card { border-radius: 8px; }
.stat-card { padding: 1rem; }
.stat-card .value { font-size: 1.4rem; }
.final-price { font-size: 1.5rem; }
}
</style>
</head>
<body>
<!-- ── Mobile top bar (d-md-none = visible seulement < 768px) ─────────── -->
<nav id="mobileTopBar"
class="d-flex d-md-none align-items-center sticky-top px-3 gap-2"
style="background:#1a1d23;height:52px;z-index:1100">
<button class="btn p-1" style="color:#9ba3af;font-size:1.3rem;line-height:1"
data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Menu">
<i class="bi bi-list"></i>
</button>
<span class="text-white fw-bold">
<i class="bi bi-layers-half me-1"></i>3D<span style="color:#ff6b35">Pricing</span>
</span>
</nav>
<!-- ── Mobile offcanvas sidebar ──────────────────────────────────────── -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar"
style="background:#1a1d23;width:240px">
<div class="offcanvas-header" style="border-bottom:1px solid #2e323d;padding:.75rem 1.25rem">
<span class="text-white fw-bold">
<i class="bi bi-layers-half me-1"></i>3D<span style="color:#ff6b35">Pricing</span>
</span>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body p-0" style="overflow-y:auto">
<div id="mobileNavInner"></div>
</div>
</div>
<div id="sidebar">
<div class="brand">
<span class="brand-text"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></span>
@@ -221,6 +260,33 @@
});
})();
</script>
<!-- ── Peuple l'offcanvas mobile depuis le sidebar desktop ───────────── -->
<script>
(function () {
const sidebarNav = document.querySelector('#sidebar nav');
const sidebarUser = document.querySelector('#sidebar .sidebar-user');
const mobileInner = document.getElementById('mobileNavInner');
if (!sidebarNav || !mobileInner) return;
// Clone le nav et force l'affichage des labels (jamais en mode collapsed)
const navClone = sidebarNav.cloneNode(true);
navClone.querySelectorAll('.nav-label, .nav-section').forEach(el => {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
});
// Ferme l'offcanvas au clic sur un lien
navClone.querySelectorAll('a').forEach(a => a.setAttribute('data-bs-dismiss', 'offcanvas'));
mobileInner.appendChild(navClone);
// Clone la zone utilisateur en bas
if (sidebarUser) {
const userClone = sidebarUser.cloneNode(true);
userClone.style.cssText += ';opacity:1;pointer-events:auto;margin-top:auto';
userClone.querySelectorAll('a').forEach(a => a.setAttribute('data-bs-dismiss', 'offcanvas'));
mobileInner.appendChild(userClone);
}
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+2
View File
@@ -41,6 +41,7 @@
<div class="card mb-3">
<div class="card-header py-3">Informations</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm mb-0">
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
<tr><th class="text-muted fw-normal">Matiere</th>
@@ -97,6 +98,7 @@
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
{% endif %}
</table>
</div>
{% if job.notes %}
<hr>
<small class="text-muted">{{ job.notes }}</small>
+2
View File
@@ -17,6 +17,7 @@
<div class="card">
<div class="card-body p-0">
{% if jobs %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
@@ -61,6 +62,7 @@
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1"></i>
+121 -24
View File
@@ -126,6 +126,9 @@
<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 id="modalError" class="alert alert-danger py-2 small d-none">
<i class="bi bi-exclamation-triangle me-1"></i><span></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
@@ -215,6 +218,58 @@ const STATUS_LABELS = {
let timeline, items, groups;
/**
* Calcule les périodes où le nombre de slots actifs atteint max_printers
* et retourne des items background vis.js pour les afficher en rouge.
*/
function buildCapacityBands(slots, maxPrinters) {
const active = slots.filter(s => !['cancelled','failed','done'].includes(s.status));
if (active.length < maxPrinters) return [];
// Collecter tous les événements (début/fin)
const events = [];
active.forEach(s => {
events.push({ t: new Date(s.planned_start), d: +1 });
events.push({ t: new Date(s.planned_end), d: -1 });
});
events.sort((a, b) => a.t - b.t);
const bands = [];
let count = 0;
let bandStart = null;
let bgId = 0;
for (const ev of events) {
count += ev.d;
if (count >= maxPrinters && bandStart === null) {
bandStart = ev.t;
} else if (count < maxPrinters && bandStart !== null) {
bands.push({
id: 'cap_' + (bgId++),
start: bandStart,
end: ev.t,
type: 'background',
className: '',
style: 'background: rgba(239,68,68,.18); border-left: 2px solid rgba(239,68,68,.5);',
title: `⚠️ Capacité max atteinte (${maxPrinters} impressions simultanées)`,
});
bandStart = null;
}
}
if (bandStart !== null) {
bands.push({
id: 'cap_' + (bgId++),
start: bandStart,
end: new Date(bandStart.getTime() + 86400000),
type: 'background',
className: '',
style: 'background: rgba(239,68,68,.18); border-left: 2px solid rgba(239,68,68,.5);',
title: `⚠️ Capacité max atteinte (${maxPrinters} impressions simultanées)`,
});
}
return bands;
}
async function initTimeline() {
const [slotsRes, availRes] = await Promise.all([
fetch('/api/slots').then(r => r.json()),
@@ -239,17 +294,33 @@ async function initTimeline() {
}));
// 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`,
}));
const slotItems = slotsRes.map(s => {
const h = Math.floor(s.print_time_s / 3600);
const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0');
const dur = `${h}h${mn}`;
const dot = s.material_color
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.material_color};border:1px solid rgba(255,255,255,.5);margin-right:3px;flex-shrink:0;vertical-align:middle"></span>`
: '';
const platePart = s.plate_name ? ` · ${s.plate_name}` : '';
const filLine = s.material_name ? `<br>🧵 ${s.material_name} ${s.material_type || ''}` : '';
const clientLine = s.client_name ? `<br>👤 ${s.client_name}` : '';
const plateLine = s.plate_name ? `<br>📋 ${s.plate_name}` : '';
return {
id: s.id,
group: s.printer_id,
start: s.planned_start,
end: s.planned_end,
content: `<span style="font-size:.7rem;display:flex;align-items:center;gap:2px;white-space:nowrap">${dot}<strong>${s.job_name}</strong><span style="opacity:.75;font-weight:normal"> (${dur})</span></span>`,
title: `<strong>${s.job_name}</strong>${plateLine}${clientLine}<br>⏱ ${dur}${filLine}<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]);
// Bandes rouges "capacité saturée" (toutes imprimantes, groupe null = full-width background)
const MAX_PRINTERS = {{ settings.get('max_simultaneous_printers', 5) }};
const capacityBgItems = buildCapacityBands(slotsRes, MAX_PRINTERS);
items = new vis.DataSet([...bgItems, ...slotItems, ...capacityBgItems]);
const now = new Date();
timeline = new vis.Timeline(
@@ -290,20 +361,41 @@ function renderSlotsList(slots) {
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">
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 20);
el.innerHTML = `<div class="table-responsive"><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>
<th class="small">Début</th>
<th class="small">Job</th>
<th class="small">Durée</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,'&quot;')})">
<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>`;
${upcoming.map(s => {
const h = Math.floor(s.print_time_s / 3600);
const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0');
const dur = `${h}h${mn}`;
const dot = s.material_color
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.material_color};border:1px solid #ccc;margin-right:3px;vertical-align:middle"></span>`
: '';
const platePart = s.plate_name
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>${s.plate_name}</span>`
: '';
const filPart = s.material_name
? `<br><span style="font-size:.7rem;color:#6b7280">${dot}${s.material_name} ${s.material_type||''}</span>`
: '';
const clientPart = s.client_name
? `<br><span class="text-muted fw-normal" style="font-size:.75rem">${s.client_name}</span>`
: '';
return `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'&quot;')})">
<td class="small" style="white-space:nowrap">${formatDt(s.planned_start)}</td>
<td class="small fw-semibold">${s.job_name}${platePart}${filPart}${clientPart}</td>
<td class="small text-muted" style="white-space:nowrap">${dur}</td>
<td class="small" style="white-space:nowrap">${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></div>`;
}
function formatDt(iso) {
@@ -350,16 +442,21 @@ 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', {
// Clear previous error
document.getElementById('modalError').classList.add('d-none');
const r = 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());
});
const res = await r.json();
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
location.reload();
} else {
alert('Erreur : ' + (res.error || 'inconnu'));
const errEl = document.getElementById('modalError');
errEl.querySelector('span').textContent = res.error || 'Erreur inconnue';
errEl.classList.remove('d-none');
}
}