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 jobs ADD COLUMN plate_name TEXT DEFAULT ""',
|
||||||
'ALTER TABLE printers ADD COLUMN ha_entity_prefix 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_ignored INTEGER DEFAULT 0',
|
||||||
|
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
|
||||||
]
|
]
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -1631,6 +1632,15 @@ def api_slots():
|
|||||||
SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end,
|
SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end,
|
||||||
ps.status, ps.failure_note,
|
ps.status, ps.failure_note,
|
||||||
COALESCE(ps.pieces_ignored, 0) as pieces_ignored,
|
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.name as job_name, j.print_time_s, j.final_price,
|
||||||
j.pieces_per_plate, j.order_qty, j.weight_g,
|
j.pieces_per_plate, j.order_qty, j.weight_g,
|
||||||
j.plate_name,
|
j.plate_name,
|
||||||
@@ -1647,6 +1657,21 @@ def api_slots():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return jsonify([dict(s) for s in slots])
|
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):
|
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.
|
"""Vérifie si ajouter ce créneau dépasserait max_printers simultanés.
|
||||||
Retourne (True, peak_count) si limite atteinte, (False, peak_count) sinon.
|
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>';
|
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;
|
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">
|
el.innerHTML = `<div class="table-responsive"><table class="table table-sm table-hover mb-0">
|
||||||
<thead class="table-light"><tr>
|
<thead class="table-light"><tr>
|
||||||
<th class="small">Début</th>
|
<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">Durée</th>
|
||||||
<th class="small">Machine</th>
|
<th class="small">Machine</th>
|
||||||
<th class="small">Statut</th>
|
<th class="small">Statut</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>${rows}</tbody>
|
||||||
${upcoming.map(s => {
|
</table></div>`;
|
||||||
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}`;
|
async function applyCompensation(slotId, currentPieces, deficit) {
|
||||||
const dot = s.material_color
|
const newVal = currentPieces + deficit;
|
||||||
? `<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>`
|
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`, {
|
||||||
const platePart = s.plate_name
|
method: 'POST',
|
||||||
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>${s.plate_name}</span>`
|
headers: {'Content-Type':'application/json'},
|
||||||
: '';
|
body: JSON.stringify({pieces: newVal})
|
||||||
const filPart = s.material_name
|
}).then(r => r.json());
|
||||||
? `<br><span style="font-size:.7rem;color:#6b7280">${dot}${s.material_name} ${s.material_type||''}</span>`
|
if (res.ok) loadSlots();
|
||||||
: '';
|
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDt(iso) {
|
function formatDt(iso) {
|
||||||
|
|||||||
Reference in New Issue
Block a user