feat: max simultaneous printers enforced + capacity bands on Gantt
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,'"')})">
|
||||
<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,'"')})">
|
||||
<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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user