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
|
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
|
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools, threading, time
|
||||||
try:
|
try:
|
||||||
@@ -75,6 +96,12 @@ def require_login_global():
|
|||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
def get_db():
|
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 = sqlite3.connect(DATABASE, timeout=30)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute('PRAGMA journal_mode=WAL')
|
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_ignored INTEGER DEFAULT 0',
|
||||||
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
|
'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL',
|
||||||
'ALTER TABLE calendar_blocks ADD COLUMN date_end TEXT 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:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -429,6 +457,38 @@ def parse_3mf(file_stream):
|
|||||||
|
|
||||||
def calc(weight_g, print_time_s, design_mult, gross_margin_pct, discount_pct,
|
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):
|
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:
|
if s is None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
if pieces_per_plate < 1:
|
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):
|
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()
|
s = get_settings()
|
||||||
cooldown_min = int(s.get('cooldown_minutes', 15))
|
cooldown_min = int(s.get('cooldown_minutes', 15))
|
||||||
max_printers = int(s.get('max_simultaneous_printers', 5))
|
max_printers = int(s.get('max_simultaneous_printers', 5))
|
||||||
@@ -1194,6 +1260,7 @@ def new_job():
|
|||||||
prefill = dict(prefill)
|
prefill = dict(prefill)
|
||||||
prefill['hours'] = prefill['print_time_s'] // 3600
|
prefill['hours'] = prefill['print_time_s'] // 3600
|
||||||
prefill['minutes'] = (prefill['print_time_s'] % 3600) // 60
|
prefill['minutes'] = (prefill['print_time_s'] % 3600) // 60
|
||||||
|
# source_nc_path transmis au template pour auto-parse Nextcloud
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
weight_g = float(request.form['weight_g'])
|
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)))
|
pieces_per_plate = max(1, int(request.form.get('pieces_per_plate', 1)))
|
||||||
order_qty = max(1, int(request.form.get('order_qty', 1)))
|
order_qty = max(1, int(request.form.get('order_qty', 1)))
|
||||||
plate_name = request.form.get('plate_name', '').strip()
|
plate_name = request.form.get('plate_name', '').strip()
|
||||||
|
source_nc_path = request.form.get('source_nc_path', '').strip()
|
||||||
|
|
||||||
s = dict(settings)
|
s = dict(settings)
|
||||||
if machine_pid:
|
if machine_pid:
|
||||||
@@ -1246,16 +1314,16 @@ def new_job():
|
|||||||
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount,
|
r = calc(weight_g, print_time_s, design_mult, gross_margin, discount,
|
||||||
price_kg, s, pieces_per_plate)
|
price_kg, s, pieces_per_plate)
|
||||||
conn.execute('''INSERT INTO jobs(client_id,material_id,machine_profile_id,handling_profile_id,
|
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,
|
weight_g,print_time_s,design_multiplier,gross_margin_pct,discount_pct,
|
||||||
pieces_per_plate,order_qty,
|
pieces_per_plate,order_qty,
|
||||||
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
|
material_cost,material_margin,design_cost,handling_cost,wear_cost,electricity_cost,
|
||||||
cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht,
|
cout_fixe,total_marge,subtotal,margin_amount,marge_pct_on_ht,
|
||||||
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
|
cotisations_amount,vfl_amount,tva_amount,other_taxes_amount,tax_amount,
|
||||||
price_per_piece,final_price,notes)
|
price_per_piece,final_price,notes)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||||||
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id,
|
(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,
|
weight_g,print_time_s,design_mult,gross_margin,discount,
|
||||||
pieces_per_plate,order_qty,
|
pieces_per_plate,order_qty,
|
||||||
r['material_cost'],r['material_margin'],r['design_cost'],r['handling_cost'],
|
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):
|
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.
|
Vérifie que le nombre max d'imprimantes simultanées ne sera pas dépassé.
|
||||||
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).
|
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
|
q = """SELECT planned_start, planned_end FROM print_slots
|
||||||
WHERE status NOT IN ('cancelled','failed','done')
|
WHERE status NOT IN ('cancelled','failed','done')
|
||||||
@@ -2334,6 +2411,31 @@ def client_portal(token):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return render_template('client_portal.html', job=job, slots=slots)
|
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')
|
@app.route('/api/nextcloud/file')
|
||||||
def nc_file():
|
def nc_file():
|
||||||
"""Proxy WebDAV : télécharge un fichier Nextcloud et le sert au navigateur."""
|
"""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"
|
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||||||
placeholder="nom_fichier.3mf"
|
placeholder="nom_fichier.3mf"
|
||||||
value="{{ prefill.source_file if prefill else '' }}">
|
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"
|
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
|
||||||
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
||||||
title="Parcourir Nextcloud"
|
title="Parcourir Nextcloud"
|
||||||
@@ -883,6 +885,9 @@ let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu'
|
|||||||
}
|
}
|
||||||
bootstrap.Modal.getInstance(modal).hide();
|
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)
|
// Mode Bambu : télécharger et parser le 3MF (gcode.3mf ou projet)
|
||||||
if (ncPickerMode === 'bambu' && (is3mf || isGcode3mf)) {
|
if (ncPickerMode === 'bambu' && (is3mf || isGcode3mf)) {
|
||||||
const status = document.getElementById('parseStatus');
|
const status = document.getElementById('parseStatus');
|
||||||
@@ -904,6 +909,32 @@ let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu'
|
|||||||
ncPickerMode = 'source'; // reset
|
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
|
// Ouvrir = charger le dossier racine + afficher/masquer hint bambu
|
||||||
modal.addEventListener('show.bs.modal', () => {
|
modal.addEventListener('show.bs.modal', () => {
|
||||||
bambuHint.classList.toggle('d-none', ncPickerMode !== 'bambu');
|
bambuHint.classList.toggle('d-none', ncPickerMode !== 'bambu');
|
||||||
|
|||||||
Reference in New Issue
Block a user