feat: multi-plate grouping, compensation badges, pieces_override on slots
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -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
@@ -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,'"')})">
|
||||
<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,'"')})">
|
||||
<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) {
|
||||
|
||||
Reference in New Issue
Block a user