feat: auto-reparse Nextcloud 3mf on job clone (source_nc_path stored in DB)
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 14:59:04 +02:00
parent 0069ab2592
commit 3da19f55a5
2 changed files with 141 additions and 8 deletions
+110 -8
View File
@@ -1,3 +1,24 @@
"""
3D Printing Pricing & Planning App
====================================
Flask/SQLite application for managing 3D print job costing, scheduling,
and Home Assistant sync for a small print shop.
Architecture:
- Flask routes (server-rendered Jinja2 templates)
- SQLite database with WAL mode (concurrent read/write safe)
- Background thread for Home Assistant polling
- vis.js Timeline Gantt on the planning page
- Optional OIDC authentication via Authelia
Key modules:
calc() — core pricing engine (per piece, with fiscal charges)
parse_3mf() — Bambu Studio .3mf file parser
find_next_slot() — automatic next available printer slot finder
_ha_sync_once() — Home Assistant state → slot status synchronization
planning() — Gantt + unscheduled jobs view
"""
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response, session, flash
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools, threading, time
try:
@@ -75,6 +96,12 @@ def require_login_global():
return redirect(url_for('login'))
def get_db():
"""
Ouvre une connexion SQLite avec WAL mode activé.
WAL (Write-Ahead Log) permet lectures et écritures simultanées
sans blocage — critique pour le thread HA en arrière-plan.
Timeout 30s pour attendre si une autre écriture est en cours.
"""
conn = sqlite3.connect(DATABASE, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode=WAL')
@@ -274,6 +301,7 @@ def init_db():
'ALTER TABLE print_slots ADD COLUMN pieces_ignored INTEGER DEFAULT 0',
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
'ALTER TABLE calendar_blocks ADD COLUMN date_end TEXT DEFAULT NULL',
'ALTER TABLE jobs ADD COLUMN source_nc_path TEXT DEFAULT ""',
]
for sql in migrations:
try:
@@ -429,6 +457,38 @@ def parse_3mf(file_stream):
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
price_per_kg_override=None, s=None, pieces_per_plate=1):
"""
Moteur de pricing principal — calcule le prix de vente d'une pièce.
Flux de calcul (toutes les valeurs sont PAR PIÈCE) :
1. Coût matière = prix_kg/1000 × poids_plateau / pièces_plateau
2. Marge matière = coût_matière × material_margin_pct
3. Design = coût_matière × design_multiplier
4. Manutention = taux_horaire × minutes_plateau / 60 / pièces
5. Usure machine = (prix_machine / durée_vie) × temps_impression / pièces
6. Électricité = puissance_kw × heures × prix_kwh / pièces
7. Coût fixe = matière + marge_matière + usure + élec
8. Sous-total = coût_fixe + manutention + design
9. Marge brute = sous-total × gross_margin_pct / 100
10. Total marge = manutention + design + marge_brute
11. Prix HT = coût_fixe + total_marge
12. Prix HT net = prix_ht / (1 - charges_fiscales)
(cotisations + VFL + autres taxes sont self-contained dans le prix)
13. Prix TTC = prix_ht_net × (1 + tva)
14. Prix final = prix_ttc × (1 - remise)
Args:
weight_g: poids total du plateau Bambu (en g)
print_time_s: durée d'impression plateau en secondes
design_mult: multiplicateur design (ex: 0.8 ou 1.8)
gross_margin_pct: marge brute en % (ex: 27.5)
discount_pct: remise en % (ex: 10.0)
price_per_kg_override: prix filament override (sinon settings)
s: dict settings (sinon get_settings() appelé)
pieces_per_plate: nb pièces par plateau (divise les coûts fixes)
Returns: dict avec tous les postes de coût et prix final.
"""
if s is None:
s = get_settings()
if pieces_per_plate < 1:
@@ -602,7 +662,13 @@ def calc_capacity(print_time_s, pieces_per_plate, days_ahead=30):
}
def find_next_slot(print_time_s):
"""Trouve le prochain créneau libre sur une imprimante disponible."""
"""
Trouve le prochain créneau libre sur une imprimante disponible.
Parcourt jusqu'à 60 jours, en tenant compte du planning horaire
(TT vs onsite) et des indisponibilités (calendar_blocks).
Retourne un dict {printer_id, printer_name, planned_start, planned_end}
ou None si aucun créneau trouvé dans les 60 jours.
"""
s = get_settings()
cooldown_min = int(s.get('cooldown_minutes', 15))
max_printers = int(s.get('max_simultaneous_printers', 5))
@@ -1194,6 +1260,7 @@ def new_job():
prefill = dict(prefill)
prefill['hours'] = prefill['print_time_s'] // 3600
prefill['minutes'] = (prefill['print_time_s'] % 3600) // 60
# source_nc_path transmis au template pour auto-parse Nextcloud
if request.method == 'POST':
weight_g = float(request.form['weight_g'])
@@ -1214,6 +1281,7 @@ def new_job():
pieces_per_plate = max(1, int(request.form.get('pieces_per_plate', 1)))
order_qty = max(1, int(request.form.get('order_qty', 1)))
plate_name = request.form.get('plate_name', '').strip()
source_nc_path = request.form.get('source_nc_path', '').strip()
s = dict(settings)
if machine_pid:
@@ -1246,16 +1314,16 @@ def new_job():
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount,
price_kg, s, pieces_per_plate)
conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id,
material_profile_id,pricing_profile_id,project_id,source_file,plate_name,name,description,
material_profile_id,pricing_profile_id,project_id,source_file,source_nc_path,plate_name,name,description,
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
pieces_per_plate,order_qty,
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht,
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
price_per_piece,final_price,notes)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id,
source_file,plate_name,request.form['name'],request.form.get('description',''),
source_file,source_nc_path,plate_name,request.form['name'],request.form.get('description',''),
weight_g,print_time_s,design_mult,gross_margin,discount,
pieces_per_plate,order_qty,
r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'],
@@ -1725,10 +1793,19 @@ def api_slot_pieces_override(id):
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).
"""
Vérifie que le nombre max d'imprimantes simultanées ne sera pas dépassé.
Méthode event-point :
1. Récupère tous les slots actifs qui chevauchent la fenêtre demandée
2. Si < max_printers, retourne False immédiatement
3. Sinon, teste le nb de slots actifs à chaque heure de début
(points d'événement) et retourne True si le pic atteint max_printers.
Note: n'inclut que les slots dont planned_end > now (évite de bloquer
sur des impressions physiquement terminées mais non clôturées).
Returns: (bool over_capacity, int peak_count)
"""
q = """SELECT planned_start, planned_end FROM print_slots
WHERE status NOT IN ('cancelled','failed','done')
@@ -2334,6 +2411,31 @@ def client_portal(token):
conn.close()
return render_template('client_portal.html', job=job, slots=slots)
@app.route('/api/nextcloud/parse')
def nc_parse():
"""Fetch un fichier .3mf depuis Nextcloud et retourne les données parsées (JSON).
Utilisé lors de la duplication d'un job pour recharger les plateaux sans re-sélectionner le fichier."""
path = request.args.get('path', '')
if not path:
return jsonify({'error': 'Paramètre path manquant'}), 400
sess, base_url, username = _nc_session()
if not sess:
return jsonify({'error': 'Nextcloud non configuré'}), 503
dav_url = _dav_url(base_url, username, path)
try:
r = sess.get(dav_url, timeout=30)
if r.status_code != 200:
return jsonify({'error': f'Erreur WebDAV {r.status_code}'}), 502
import io as _io
result = parse_3mf(_io.BytesIO(r.content))
filename = path.split('/')[-1]
clean = re.sub(r'\.gcode\.3mf$', '', filename, flags=re.IGNORECASE)
clean = re.sub(r'\.3mf$', '', clean, flags=re.IGNORECASE)
result['filename'] = clean
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/nextcloud/file')
def nc_file():
"""Proxy WebDAV : télécharge un fichier Nextcloud et le sert au navigateur."""
+31
View File
@@ -67,6 +67,8 @@
<input type="text" name="source_file" id="sourceFile" class="form-control"
placeholder="nom_fichier.3mf"
value="{{ prefill.source_file if prefill else '' }}">
<input type="hidden" name="source_nc_path" id="sourceNcPath"
value="{{ prefill.source_nc_path if prefill else '' }}">
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
title="Parcourir Nextcloud"
@@ -883,6 +885,9 @@ let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu'
}
bootstrap.Modal.getInstance(modal).hide();
// Toujours sauvegarder le chemin Nextcloud pour permettre la duplication
document.getElementById('sourceNcPath').value = item.path;
// Mode Bambu : télécharger et parser le 3MF (gcode.3mf ou projet)
if (ncPickerMode === 'bambu' && (is3mf || isGcode3mf)) {
const status = document.getElementById('parseStatus');
@@ -904,6 +909,32 @@ let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu'
ncPickerMode = 'source'; // reset
}
// ── Auto-parse au chargement (duplication avec source_nc_path) ────────────
(async function autoParseOnClone() {
const ncPath = document.getElementById('sourceNcPath').value;
if (!ncPath) return;
const status = document.getElementById('parseStatus');
if (!status) return;
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Rechargement des plateaux…';
try {
const r = await fetch('/api/nextcloud/parse?path=' + encodeURIComponent(ncPath));
const d = await r.json();
if (d.error) { status.innerHTML = `<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>${d.error}</span>`; return; }
if (d.plates && d.plates.length > 1) {
_3mfPlates = d.plates;
renderPlateSelector(d.plates);
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) disponibles — choisissez ou modifiez</span>`;
} else if (d.plates && d.plates.length === 1) {
status.innerHTML = '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>';
} else {
status.innerHTML = '<span class="text-muted small">Fichier source lié — plateaux non disponibles</span>';
}
} catch(e) {
status.innerHTML = '<span class="text-muted small">Fichier source lié (réimportez si besoin)</span>';
}
})();
// Ouvrir = charger le dossier racine + afficher/masquer hint bambu
modal.addEventListener('show.bs.modal', () => {
bambuHint.classList.toggle('d-none', ncPickerMode !== 'bambu');