feat: multi-plate grouping, compensation badges, pieces_override on slots
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 11:58:09 +02:00
parent 935a2f9634
commit de9ecec6dc
2 changed files with 146 additions and 31 deletions
+25
View File
@@ -268,6 +268,7 @@ def init_db():
'ALTER TABLE jobs ADD COLUMN plate_name TEXT DEFAULT ""',
'ALTER TABLE printers ADD COLUMN ha_entity_prefix TEXT DEFAULT ""',
'ALTER TABLE print_slots ADD COLUMN pieces_ignored INTEGER DEFAULT 0',
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
]
for sql in migrations:
try:
@@ -1631,6 +1632,15 @@ def api_slots():
SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end,
ps.status, ps.failure_note,
COALESCE(ps.pieces_ignored, 0) as pieces_ignored,
ps.pieces_override,
COALESCE(ps.pieces_override, j.pieces_per_plate) as effective_pieces,
(SELECT COUNT(*) FROM print_slots ps2
WHERE ps2.job_id = ps.job_id
AND ps2.planned_start <= ps.planned_start
AND ps2.id <= ps.id) as plate_num,
(SELECT COUNT(*) FROM print_slots WHERE job_id = ps.job_id) as total_plates,
(SELECT COALESCE(SUM(COALESCE(pieces_ignored,0)), 0) FROM print_slots
WHERE job_id = ps.job_id) as job_total_ignored,
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,
@@ -1647,6 +1657,21 @@ def api_slots():
conn.close()
return jsonify([dict(s) for s in slots])
@app.route('/api/slots/<int:id>/pieces_override', methods=['POST'])
@login_required
def api_slot_pieces_override(id):
d = request.get_json(silent=True) or {}
pieces = d.get('pieces')
conn = get_db()
if pieces is None:
conn.execute('UPDATE print_slots SET pieces_override=NULL WHERE id=?', (id,))
else:
conn.execute('UPDATE print_slots SET pieces_override=? WHERE id=?', (max(1, int(pieces)), id))
conn.commit()
conn.close()
return jsonify({'ok': True})
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.
+121 -31
View File
@@ -376,44 +376,134 @@ 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, 20);
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 30);
// Pre-compute per-job stats using job_total_ignored from API (covers all slots, even old ones)
const jobMeta = {};
for (const s of upcoming) {
if (!jobMeta[s.job_id]) {
jobMeta[s.job_id] = {
name: s.job_name,
totalPlates: s.total_plates,
totalIgnored: s.job_total_ignored, // sum across ALL slots of job
pendingSlots: []
};
}
if (['planned','running'].includes(s.status)) jobMeta[s.job_id].pendingSlots.push(s);
}
let rows = '';
const seenJobs = new Set();
for (const s of upcoming) {
const job = jobMeta[s.job_id];
const isMulti = job.totalPlates > 1;
// ── Job group header (once per multi-plate job) ──────────────────────
if (isMulti && !seenJobs.has(s.job_id)) {
seenJobs.add(s.job_id);
const deficit = job.totalIgnored;
const alreadyCompensated = job.pendingSlots.reduce((sum, sl) =>
sum + (sl.pieces_override !== null ? Math.max(0, sl.pieces_override - sl.pieces_per_plate) : 0), 0);
const remaining = deficit - alreadyCompensated;
const defBadge = remaining > 0
? `<span class="badge ms-2" style="background:#fbbf24;color:#78350f;font-size:.65rem">
<i class="bi bi-exclamation-triangle-fill me-1"></i>${remaining} pièce(s) à compenser
</span>`
: (deficit > 0
? `<span class="badge bg-success ms-2" style="font-size:.65rem"><i class="bi bi-check-circle-fill me-1"></i>Compensé</span>`
: '');
rows += `<tr style="background:#eef2ff;border-top:2px solid #c7d2fe">
<td colspan="5" style="padding:.3rem .75rem">
<span style="font-size:.78rem;font-weight:600;color:#3730a3">
<i class="bi bi-layers-half me-1"></i>${s.job_name} — ${job.totalPlates} plateaux
</span>${defBadge}
</td>
</tr>`;
}
// ── Individual slot row ──────────────────────────────────────────────
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>`
: '';
// Plate label: for multi-plate show "Plateau X/Y", else fallback to plate_name
const platePart = isMulti
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>Plateau ${s.plate_num}/${s.total_plates}</span>`
: (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>`
: '';
// Pieces delivered on this slot (if already printed with ignores)
const ignoredPart = s.pieces_ignored > 0
? `<br><span style="font-size:.7rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px">
<i class="bi bi-exclamation-triangle-fill me-1"></i>${s.effective_pieces - s.pieces_ignored}/${s.effective_pieces} pièces livrées
</span>`
: '';
// Override badge (slot was manually adjusted)
const overridePart = s.pieces_override !== null
? `<br><span style="font-size:.7rem;background:#dcfce7;color:#166534;padding:1px 5px;border-radius:3px">
<i class="bi bi-check-circle-fill me-1"></i>${s.pieces_override} pièces (ajusté)
</span>`
: '';
// Compensation needed on this pending slot
const isPending = ['planned','running'].includes(s.status);
const deficit = job.totalIgnored;
const alreadyComp = job.pendingSlots.reduce((sum, sl) =>
sum + (sl.pieces_override !== null ? Math.max(0, sl.pieces_override - sl.pieces_per_plate) : 0), 0);
const stillNeeded = deficit - alreadyComp;
const compensatePart = (isPending && stillNeeded > 0 && s.pieces_ignored === 0 && s.pieces_override === null)
? `<br><span style="font-size:.7rem;background:#fff7ed;color:#9a3412;padding:1px 6px;border-radius:3px;cursor:pointer"
onclick="event.stopPropagation();applyCompensation(${s.id}, ${s.effective_pieces}, ${stillNeeded})">
<i class="bi bi-arrow-up-circle-fill me-1"></i>Compenser : imprimer ${s.effective_pieces + stillNeeded} ✏️
</span>`
: '';
// For multi-plate jobs, don't repeat the job name in each row (it's in the header)
const jobLabel = isMulti ? '' : `${s.job_name}`;
rows += `<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">${jobLabel}${platePart}${filPart}${clientPart}${ignoredPart}${overridePart}${compensatePart}</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>`;
}
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">Job / Plateau</th>
<th class="small">Durée</th>
<th class="small">Machine</th>
<th class="small">Statut</th>
</tr></thead>
<tbody>
${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>`
: '';
const ignoredPart = (s.pieces_ignored > 0)
? `<br><span style="font-size:.7rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px"><i class="bi bi-exclamation-triangle-fill me-1"></i>${s.pieces_per_plate - s.pieces_ignored}/${s.pieces_per_plate} pièces</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}${ignoredPart}${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>`;
<tbody>${rows}</tbody>
</table></div>`;
}
async function applyCompensation(slotId, currentPieces, deficit) {
const newVal = currentPieces + deficit;
if (!confirm(`Ajuster ce plateau à ${newVal} pièces (au lieu de ${currentPieces}) pour compenser les ${deficit} pièce(s) ignorée(s) ?`)) return;
const res = await fetch(`/api/slots/${slotId}/pieces_override`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({pieces: newVal})
}).then(r => r.json());
if (res.ok) loadSlots();
}
function formatDt(iso) {