feat: auto-reparse Nextcloud 3mf on job clone (source_nc_path stored in DB)
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user