Files
3d-pricing/app.py
T
2026-07-07 14:59:04 +02:00

2558 lines
117 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
import requests as _requests
except ImportError:
_requests = None
from datetime import datetime, timedelta, date as date_cls, timezone
from authlib.integrations.flask_client import OAuth
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db')
# ─── OIDC ─────────────────────────────────────────────────────────────────────
OIDC_ENABLED = bool(os.environ.get('OIDC_CLIENT_ID'))
oauth = OAuth(app)
if OIDC_ENABLED:
oauth.register(
name='authelia',
client_id=os.environ.get('OIDC_CLIENT_ID'),
client_secret=os.environ.get('OIDC_CLIENT_SECRET'),
server_metadata_url=os.environ.get('OIDC_DISCOVERY_URL'),
client_kwargs={
'scope': 'openid profile email',
'code_challenge_method': 'S256', # PKCE
},
)
def login_required(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
if OIDC_ENABLED and 'user' not in session:
session['next'] = request.url
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
@app.route('/login')
def login():
if not OIDC_ENABLED:
return redirect(url_for('dashboard'))
redirect_uri = url_for('auth_callback', _external=True)
return oauth.authelia.authorize_redirect(redirect_uri)
@app.route('/auth/callback')
def auth_callback():
token = oauth.authelia.authorize_access_token()
userinfo = token.get('userinfo') or oauth.authelia.userinfo()
session['user'] = {
'sub': userinfo.get('sub', ''),
'name': userinfo.get('name') or userinfo.get('preferred_username', 'Utilisateur'),
'email': userinfo.get('email', ''),
}
next_url = session.pop('next', None)
return redirect(next_url or url_for('dashboard'))
@app.route('/logout')
def logout():
session.clear()
if OIDC_ENABLED:
end_session = os.environ.get('OIDC_END_SESSION_URL', '')
if end_session:
post_logout = url_for('login', _external=True)
return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}")
return redirect(url_for('login'))
# Routes accessibles sans login
_OPEN_ROUTES = {'login', 'auth_callback', 'logout', 'static', 'client_portal', 'calendar_ics'}
@app.before_request
def require_login_global():
if OIDC_ENABLED and request.endpoint not in _OPEN_ROUTES and 'user' not in session:
session['next'] = request.url
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')
conn.execute('PRAGMA synchronous=NORMAL')
return conn
def init_db():
os.makedirs(os.path.dirname(DATABASE), exist_ok=True)
conn = get_db()
conn.executescript('''
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
deadline TEXT DEFAULT NULL,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, email TEXT DEFAULT '',
design_multiplier REAL DEFAULT 1.80, notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, brand TEXT DEFAULT '', type TEXT DEFAULT 'PLA',
color TEXT DEFAULT '', price_per_kg REAL NOT NULL DEFAULT 25.0,
stock_g REAL DEFAULT 0, low_stock_threshold_g REAL DEFAULT 200,
notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS material_price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
price_per_kg REAL NOT NULL, recorded_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS machine_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
printer_price REAL DEFAULT 400.0,
printer_lifespan_hours REAL DEFAULT 5000,
nozzle_price REAL DEFAULT 10.0,
nozzle_lifespan_hours REAL DEFAULT 250,
plate_price REAL DEFAULT 25.0,
plate_lifespan_hours REAL DEFAULT 1000,
printer_power_kw REAL DEFAULT 0.35,
electricity_price_kwh REAL DEFAULT 0.18,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS handling_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
handling_rate_per_hour REAL DEFAULT 40.0,
handling_minutes_per_plate REAL DEFAULT 5,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS material_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
material_margin_pct REAL DEFAULT 20.0,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pricing_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
design_multiplier REAL DEFAULT 1.80,
gross_margin_pct REAL DEFAULT 27.5,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
material_id INTEGER REFERENCES materials(id) ON DELETE SET NULL,
machine_profile_id INTEGER REFERENCES machine_profiles(id) ON DELETE SET NULL,
handling_profile_id INTEGER REFERENCES handling_profiles(id) ON DELETE SET NULL,
material_profile_id INTEGER REFERENCES material_profiles(id) ON DELETE SET NULL,
pricing_profile_id INTEGER REFERENCES pricing_profiles(id) ON DELETE SET NULL,
source_file TEXT DEFAULT '', plate_name TEXT DEFAULT '', name TEXT NOT NULL,
description TEXT DEFAULT '', weight_g REAL NOT NULL,
print_time_s INTEGER NOT NULL, design_multiplier REAL NOT NULL,
gross_margin_pct REAL NOT NULL DEFAULT 27.5, discount_pct REAL DEFAULT 0,
pieces_per_plate INTEGER DEFAULT 1, order_qty INTEGER DEFAULT 1,
material_cost REAL, material_margin REAL, design_cost REAL,
handling_cost REAL, wear_cost REAL, electricity_cost REAL,
cout_fixe REAL, total_marge REAL,
subtotal REAL, margin_amount REAL,
cotisations_amount REAL, vfl_amount REAL, tva_amount REAL,
other_taxes_amount REAL,
tax_amount REAL, price_per_piece REAL, final_price REAL,
notes TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS printers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS working_schedule (
day_of_week INTEGER PRIMARY KEY,
mode TEXT DEFAULT 'onsite',
tt_window_start TEXT DEFAULT '10:00',
tt_window_end TEXT DEFAULT '22:00',
onsite_slot_1 TEXT DEFAULT '07:00',
onsite_slot_2 TEXT DEFAULT '18:30'
);
CREATE TABLE IF NOT EXISTS calendar_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type TEXT DEFAULT 'off',
printer_id INTEGER,
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS print_slots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER REFERENCES jobs(id) ON DELETE CASCADE,
printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
planned_start TEXT NOT NULL,
planned_end TEXT NOT NULL,
status TEXT DEFAULT 'planned',
failure_note TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
''')
defaults = [
('filament_price_per_kg','25.0'),('electricity_price_kwh','0.18'),
('printer_price','400.0'),('printer_lifespan_hours','5000'),
('nozzle_price','10.0'),('nozzle_lifespan_hours','250'),
('plate_price','25.0'),('plate_lifespan_hours','1000'),
('handling_rate_per_hour','40.0'),('handling_minutes_per_plate','5'),
('material_margin_pct','20.0'),
('cotisations_rate_pct','12.3'),
('vfl_rate_pct','1.0'),
('tva_rate_pct','20.0'),
('other_taxes_rate_pct','1.0'),
('printer_power_kw','0.35'),
('cooldown_minutes','15'),
('failure_buffer_pct','10'),
('max_simultaneous_printers','5'),
('nc_url',''),
('nc_username',''),
('nc_app_password',''),
('nc_root_path','/3D'),
('ical_token',''),
('ha_url',''),
('ha_token',''),
('ha_poll_interval','60'),
]
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
# Default printers: 6 A1, #1-#5 active, #6 inactive
for i in range(1, 7):
conn.execute('INSERT OR IGNORE INTO printers(id,name,is_active) VALUES(?,?,?)',
(i, f'A1 #{i}', 1 if i <= 5 else 0))
# Default working schedule: Mon/Wed/Fri = TT, others = onsite
tt_days = {0, 2, 4}
for dow in range(7):
mode = 'tt' if dow in tt_days else 'onsite'
conn.execute('''INSERT OR IGNORE INTO working_schedule
(day_of_week,mode,tt_window_start,tt_window_end,onsite_slot_1,onsite_slot_2)
VALUES(?,?,?,?,?,?)''', (dow, mode, '10:00', '22:00', '07:00', '18:30'))
# Migrations
migrations = [
'ALTER TABLE jobs ADD COLUMN machine_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN handling_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN material_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN pricing_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN cout_fixe REAL',
'ALTER TABLE jobs ADD COLUMN total_marge REAL',
'ALTER TABLE jobs ADD COLUMN cotisations_amount REAL',
'ALTER TABLE jobs ADD COLUMN vfl_amount REAL',
'ALTER TABLE jobs ADD COLUMN tva_amount REAL',
'ALTER TABLE jobs ADD COLUMN other_taxes_amount REAL',
'ALTER TABLE jobs ADD COLUMN pieces_per_plate INTEGER DEFAULT 1',
'ALTER TABLE jobs ADD COLUMN order_qty INTEGER DEFAULT 1',
'ALTER TABLE jobs ADD COLUMN price_per_piece REAL',
'ALTER TABLE clients ADD COLUMN default_machine_profile_id INTEGER',
'ALTER TABLE clients ADD COLUMN default_handling_profile_id INTEGER',
'ALTER TABLE clients ADD COLUMN default_material_profile_id INTEGER',
'ALTER TABLE clients ADD COLUMN default_pricing_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL',
'ALTER TABLE print_slots ADD COLUMN wasted_g REAL DEFAULT 0',
'ALTER TABLE materials ADD COLUMN total_wasted_g REAL DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN client_token TEXT DEFAULT NULL',
'ALTER TABLE jobs ADD COLUMN plate_name TEXT DEFAULT ""',
'ALTER TABLE jobs ADD COLUMN marge_pct_on_ht REAL DEFAULT 0',
'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_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:
conn.execute(sql)
except Exception:
pass
conn.commit(); conn.close()
def get_settings():
conn = get_db()
rows = conn.execute('SELECT key,value FROM settings').fetchall()
conn.close()
result = {}
for r in rows:
try:
result[r['key']] = float(r['value'])
except (ValueError, TypeError):
result[r['key']] = r['value'] or ''
return result
def parse_3mf(file_stream):
"""
Parse un fichier .3mf Bambu Studio.
Retourne un dict avec :
- plates : liste de plaques [{index, weight_g, print_time_s, filaments:[{type,color,used_g}]}]
- fallback : {weight_g, hours, minutes} si slice_info absent
- warning : message si le fichier n'a pas été slicé
"""
result = {'plates': [], 'weight_g': None, 'hours': None, 'minutes': None, 'warning': None}
try:
with zipfile.ZipFile(file_stream) as z:
names = z.namelist()
# ── 0. Lire les noms custom depuis model_settings.config ────────
# Bambu stocke le nom de plateau dans plater_name (pas dans slice_info)
plater_names = {} # {plate_index: name}
model_settings_candidates = [n for n in names if 'model_settings' in n.lower() and n.endswith('.config')]
if model_settings_candidates:
try:
ms_raw = z.read(model_settings_candidates[0]).decode('utf-8', errors='ignore')
ms_root = ET.fromstring(ms_raw)
for plate_el in ms_root.findall('plate'):
ms_meta = {m.get('key'): m.get('value') for m in plate_el.findall('metadata')}
pid = int(ms_meta.get('plater_id', 0))
pname = ms_meta.get('plater_name', '') or ''
if pid and pname:
plater_names[pid] = pname
except Exception:
pass
# ── 1. Bambu slice_info.config (format XML par plaque) ──────────
slice_info_candidates = [n for n in names if 'slice_info' in n.lower() and n.endswith('.config')]
if slice_info_candidates:
raw = z.read(slice_info_candidates[0]).decode('utf-8', errors='ignore')
try:
root = ET.fromstring(raw)
for plate_el in root.findall('plate'):
plate = {}
meta = {m.get('key'): m.get('value') for m in plate_el.findall('metadata')}
plate['index'] = int(meta.get('index', 0))
plate['print_time_s'] = int(meta.get('prediction', 0))
# Nom custom : priorité à model_settings, fallback sur slice_info
plate['name'] = plater_names.get(plate['index'], '') or meta.get('name', '') or ''
filaments = []
total_g = 0.0
for fil in plate_el.findall('filament'):
used_g = float(fil.get('used_g', 0) or 0)
total_g += used_g
filaments.append({
'id': fil.get('id', ''),
'type': fil.get('type', ''),
'color': fil.get('color', ''),
'used_g': round(used_g, 2),
'used_m': round(float(fil.get('used_m', 0) or 0), 2),
})
plate['weight_g'] = round(total_g, 2)
plate['filaments'] = filaments
# Pièces : compter les <object> non skippés
objs = [o for o in plate_el.findall('object') if o.get('skipped') != 'true']
plate['pieces'] = len(objs)
# Noms uniques des modèles (sans extension)
plate['object_names'] = list(dict.fromkeys(
o.get('name', '').rsplit('.', 1)[0]
for o in objs if o.get('name')
))
# Libellé lisible
h = plate['print_time_s'] // 3600
mn = (plate['print_time_s'] % 3600) // 60
name_part = f" «{plate['name']}»" if plate['name'] else ''
plate['label'] = (
f"Plateau {plate['index']}{name_part}"
f" — {plate['weight_g']} g"
f" — {h}h{mn:02d}m"
f" — {plate['pieces']} pce(s)"
)
result['plates'].append(plate)
result['plates'].sort(key=lambda p: p['index'])
except Exception:
pass
# ── 2. Fallback regex si slice_info absent ──────────────────────
if not result['plates']:
for name in names:
if not any(name.endswith(e) for e in ['.xml', '.model', '.config', '.json', '.txt']):
continue
try:
raw = z.read(name).decode('utf-8', errors='ignore')
except Exception:
continue
if result['weight_g'] is None:
for pat in [
r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)',
r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)',
r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)',
r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
result['weight_g'] = float(m.group(1).replace(',', '.'))
break
except ValueError:
pass
if result['hours'] is None:
for pat in [
r'(\d+)h\s*(\d+)m',
r'(\d+)\s*hours?\s*(\d+)\s*min',
r'printing.time[^"]*"\s*value\s*=\s*"(\d+)h\s*(\d+)',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
h, mn = int(m.group(1)), int(m.group(2))
if 0 <= h <= 500 and 0 <= mn <= 59:
result['hours'] = h
result['minutes'] = mn
break
except (ValueError, IndexError):
pass
except Exception:
pass
# Si aucune donnée trouvée, ajouter un avertissement
if not result['plates'] and result['weight_g'] is None:
result['warning'] = (
"Aucune donnée de découpe dans ce fichier. "
"Dans Bambu Studio : slicez vos plateaux puis utilisez "
"Fichier → Exporter → Exporter le fichier découpé (pas Sauvegarder le projet). "
"Le fichier exporté contiendra poids, temps et consommation filament."
)
return result
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:
pieces_per_plate = 1
# weight_g et print_time_s = valeurs PLATEAU complet (Bambu Studio)
# Tous les coûts sont ramenés à la pièce en divisant par pieces_per_plate
price_kg = price_per_kg_override if price_per_kg_override is not None else s['filament_price_per_kg']
weight_per_piece = weight_g / pieces_per_plate
mat = (price_kg / 1000) * weight_per_piece
mat_margin = mat * (s['material_margin_pct'] / 100)
design = mat * design_mult
# Manutention, usure et élec divisées par pièces/plateau
handling = (s['handling_rate_per_hour'] / 60) * s['handling_minutes_per_plate'] / pieces_per_plate
printer_w = (s['printer_price'] / (s['printer_lifespan_hours'] * 3600)) * print_time_s / pieces_per_plate
nozzle_w = (s['nozzle_price'] / (s['nozzle_lifespan_hours'] * 3600)) * print_time_s / pieces_per_plate
plate_w = (s['plate_price'] / (s['plate_lifespan_hours'] * 3600)) * print_time_s / pieces_per_plate
wear = printer_w + nozzle_w + plate_w
elec = s['printer_power_kw'] * (print_time_s / 3600) * s['electricity_price_kwh'] / pieces_per_plate
cout_fixe = mat + mat_margin + wear + elec
sous_total = cout_fixe + handling + design
margin = sous_total * (gross_margin_pct / 100)
total_marge= handling + design + margin
prix_ht = cout_fixe + total_marge
cot_rate = s.get('cotisations_rate_pct', 12.3) / 100
vfl_rate = s.get('vfl_rate_pct', 0.0) / 100
tva_rate = s.get('tva_rate_pct', 0.0) / 100
other_taxes_rate = s.get('other_taxes_rate_pct', 1.0) / 100
total_charges = cot_rate + vfl_rate + other_taxes_rate
if total_charges < 1.0:
prix_ht_net = prix_ht / (1 - total_charges)
else:
prix_ht_net = prix_ht
cot_amount = prix_ht_net * cot_rate
vfl_amount = prix_ht_net * vfl_rate
other_taxes_amount = prix_ht_net * other_taxes_rate
tva_amount = prix_ht_net * tva_rate
prix_ttc = prix_ht_net + tva_amount
final = prix_ttc * (1 - discount_pct / 100)
marge_pct_on_ht = (total_marge / prix_ht_net * 100) if prix_ht_net > 0 else 0
return {
'material_cost': round(mat, 4),
'material_margin': round(mat_margin, 4),
'wear_cost': round(wear, 4),
'printer_wear': round(printer_w, 4),
'nozzle_wear': round(nozzle_w, 4),
'plate_wear': round(plate_w, 4),
'electricity_cost': round(elec, 4),
'kwh_used': round(s['printer_power_kw'] * (print_time_s / 3600), 4),
'cout_fixe': round(cout_fixe, 4),
'handling_cost': round(handling, 4),
'design_cost': round(design, 4),
'subtotal': round(sous_total, 4),
'margin_amount': round(margin, 4),
'total_marge': round(total_marge, 4),
'marge_pct_on_ht': round(marge_pct_on_ht, 1),
'prix_ht': round(prix_ht, 4),
'cotisations_amount': round(cot_amount, 4),
'vfl_amount': round(vfl_amount, 4),
'other_taxes_amount': round(other_taxes_amount, 4),
'tva_amount': round(tva_amount, 4),
'prix_ht_net': round(prix_ht_net, 4),
'prix_ttc': round(prix_ttc, 4),
'tax_amount': round(cot_amount + vfl_amount + other_taxes_amount, 4),
'final_price': round(final, 2),
'_cot_pct': round(cot_rate * 100, 2),
'_vfl_pct': round(vfl_rate * 100, 2),
'_tva_pct': round(tva_rate * 100, 2),
'_other_taxes_pct': round(other_taxes_rate * 100, 2),
'_material_margin_pct': s['material_margin_pct'],
'_handling_rate': s['handling_rate_per_hour'],
'_handling_minutes': s['handling_minutes_per_plate'],
'_elec_price': s['electricity_price_kwh'],
'_pieces_per_plate': pieces_per_plate,
}
# ── Planning helpers ──────────────────────────────────────────────────────────
def get_schedule_dict():
conn = get_db()
rows = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall()
conn.close()
return {r['day_of_week']: dict(r) for r in rows}
def get_blocks_dict(from_date=None, to_date=None):
conn = get_db()
if from_date and to_date:
# A block overlaps [from_date, to_date] when:
# block.date <= to_date AND COALESCE(block.date_end, block.date) >= from_date
rows = conn.execute(
"""SELECT * FROM calendar_blocks
WHERE date <= ?
AND COALESCE(date_end, date) >= ?
ORDER BY date""",
(str(to_date), str(from_date))).fetchall()
else:
rows = conn.execute('SELECT * FROM calendar_blocks ORDER BY date').fetchall()
conn.close()
return [dict(r) for r in rows]
def get_day_launches(check_date, schedule, blocks, cycle_min):
"""Returns number of possible launches on a given date (per machine)."""
date_str = str(check_date)
dow = check_date.weekday()
block = next((b for b in blocks
if b['date'] <= date_str
and (b['date_end'] or b['date']) >= date_str
and b['printer_id'] is None), None)
mode = block['type'] if block else schedule[dow]['mode']
if mode == 'off':
return 0, mode
elif mode == 'tt':
sched = schedule[dow]
h0, m0 = [int(x) for x in sched['tt_window_start'].split(':')]
h1, m1 = [int(x) for x in sched['tt_window_end'].split(':')]
window_min = (h1 * 60 + m1) - (h0 * 60 + m0)
launches = max(1, int(window_min / cycle_min)) if cycle_min > 0 else 1
return launches, mode
elif mode == 'onsite':
return 2, mode
return 0, mode
def calc_capacity(print_time_s, pieces_per_plate, days_ahead=30):
"""Calcule la capacité de production sur N jours à venir."""
s = get_settings()
cooldown_min = s.get('cooldown_minutes', 15)
max_printers = int(s.get('max_simultaneous_printers', 5))
failure_buf = s.get('failure_buffer_pct', 10) / 100
cycle_min = print_time_s / 60 + cooldown_min
if cycle_min <= 0:
cycle_min = 1
conn = get_db()
active_count = conn.execute('SELECT COUNT(*) FROM printers WHERE is_active=1').fetchone()[0]
conn.close()
effective = min(active_count, max_printers)
schedule = get_schedule_dict()
today = date_cls.today()
blocks = get_blocks_dict(today, today + timedelta(days=days_ahead))
pieces_per_day_first = None
week_total = 0
month_total = 0
for offset in range(days_ahead):
d = today + timedelta(days=offset)
launches, _ = get_day_launches(d, schedule, blocks, cycle_min)
pieces = launches * pieces_per_plate * effective
if pieces_per_day_first is None and pieces > 0:
pieces_per_day_first = pieces
if offset < 7:
week_total += pieces
month_total += pieces
# Nb plateaux pour 1 commande (calculé côté appelant)
return {
'pieces_per_day': pieces_per_day_first or 0,
'pieces_per_week': week_total,
'pieces_per_month': month_total,
'effective_printers': effective,
'cycle_minutes': round(cycle_min, 1),
'failure_buffer_pct': round(failure_buf * 100, 1),
}
def find_next_slot(print_time_s):
"""
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))
total_sec = print_time_s + cooldown_min * 60
conn = get_db()
printers = conn.execute(
'SELECT * FROM printers WHERE is_active=1 ORDER BY id LIMIT ?', (max_printers,)
).fetchall()
existing = conn.execute(
"SELECT printer_id, planned_start, planned_end FROM print_slots WHERE status NOT IN ('cancelled','failed')"
).fetchall()
conn.close()
schedule = get_schedule_dict()
today = date_cls.today()
blocks = get_blocks_dict(today, today + timedelta(days=60))
# Build busy intervals per printer
busy = {}
for sl in existing:
pid = sl['printer_id']
if pid not in busy:
busy[pid] = []
busy[pid].append((sl['planned_start'], sl['planned_end']))
for offset in range(60):
d = today + timedelta(days=offset)
launches, mode = get_day_launches(d, schedule, blocks, (print_time_s / 60) + cooldown_min)
if launches == 0:
continue
date_str = str(d)
sched = schedule[d.weekday()]
# Build candidate launch times
candidates = []
if mode == 'tt':
h0, m0 = [int(x) for x in sched['tt_window_start'].split(':')]
candidates.append(datetime(d.year, d.month, d.day, h0, m0))
# Add slots every cycle within window
h1, m1 = [int(x) for x in sched['tt_window_end'].split(':')]
window_end = datetime(d.year, d.month, d.day, h1, m1)
t = candidates[0]
while True:
t = t + timedelta(seconds=total_sec)
if t >= window_end:
break
candidates.append(t)
else:
h0, m0 = [int(x) for x in sched['onsite_slot_1'].split(':')]
h1, m1 = [int(x) for x in sched['onsite_slot_2'].split(':')]
candidates.append(datetime(d.year, d.month, d.day, h0, m0))
candidates.append(datetime(d.year, d.month, d.day, h1, m1))
now = datetime.now()
for start_dt in candidates:
if start_dt <= now: # ignorer les créneaux déjà passés
continue
end_dt = start_dt + timedelta(seconds=total_sec)
for printer in printers:
pid = printer['id']
printer_busy = busy.get(pid, [])
conflict = any(
not (end_dt.isoformat() <= bs or start_dt.isoformat() >= be)
for bs, be in printer_busy
)
if not conflict:
return {
'printer_id': pid,
'printer_name': printer['name'],
'planned_start': start_dt.strftime('%Y-%m-%dT%H:%M'),
'planned_end': end_dt.strftime('%Y-%m-%dT%H:%M'),
}
return None
def fmt_time(s):
return f"{s//3600}h{(s%3600)//60:02d}m"
app.jinja_env.filters['fmt_time'] = fmt_time
_FRENCH_COLORS = {
'blanc': '#ffffff', 'noir': '#111111', 'gris': '#6b7280', 'argent': '#9ca3af',
'rouge': '#ef4444', 'rose': '#ec4899', 'fuchsia': '#d946ef',
'orange': '#f97316', 'jaune': '#eab308', 'or': '#d97706',
'vert': '#22c55e', 'olive': '#65a30d', 'emeraude': '#10b981',
'bleu': '#3b82f6', 'marine': '#1e3a8a', 'ciel': '#38bdf8', 'cyan': '#06b6d4',
'violet': '#8b5cf6', 'mauve': '#a855f7', 'indigo': '#6366f1',
'marron': '#92400e', 'beige': '#d4b896', 'creme': '#fef3c7',
'turquoise': '#14b8a6', 'or rose': '#fb7185',
'transparent': 'rgba(200,200,200,0.3)', 'naturel': '#f5f0e8',
'translucide': 'rgba(200,200,200,0.3)',
}
def css_color(value):
if not value:
return value
return _FRENCH_COLORS.get(value.strip().lower(), value)
app.jinja_env.filters['css_color'] = css_color
# ─── Dashboard ────────────────────────────────────────────────────────────────
@app.route('/')
def dashboard():
conn = get_db()
recent = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
ORDER BY j.created_at DESC LIMIT 10''').fetchall()
stats = conn.execute('''
SELECT COUNT(*) as total_jobs, ROUND(SUM(final_price),2) as total_revenue,
ROUND(AVG(gross_margin_pct),1) as avg_margin,
ROUND(SUM(CASE WHEN created_at>=date('now','-30 days') THEN final_price ELSE 0 END),2) as month_revenue
FROM jobs''').fetchone()
low_stock = conn.execute(
'SELECT * FROM materials WHERE stock_g<=low_stock_threshold_g ORDER BY stock_g ASC').fetchall()
# Restock alerts
planned_consumption = conn.execute('''
SELECT j.material_id, SUM(j.weight_g * j.order_qty) as total_g
FROM print_slots ps
JOIN jobs j ON ps.job_id=j.id
WHERE ps.status='planned' AND j.material_id IS NOT NULL
GROUP BY j.material_id''').fetchall()
conn.close()
return render_template('dashboard.html', jobs=recent, stats=stats,
low_stock=low_stock, planned_consumption=planned_consumption)
# ─── Clients ──────────────────────────────────────────────────────────────────
@app.route('/clients')
def clients():
conn = get_db()
clients = conn.execute('''
SELECT c.*, COUNT(j.id) as job_count,
ROUND(COALESCE(SUM(j.final_price),0),2) as total_revenue
FROM clients c LEFT JOIN jobs j ON j.client_id=c.id
GROUP BY c.id ORDER BY c.name''').fetchall()
conn.close()
return render_template('clients.html', clients=clients)
def _get_all_profiles(conn):
return {
'machine_profiles': conn.execute('SELECT id,name FROM machine_profiles ORDER BY name').fetchall(),
'handling_profiles': conn.execute('SELECT id,name FROM handling_profiles ORDER BY name').fetchall(),
'material_profiles': conn.execute('SELECT id,name FROM material_profiles ORDER BY name').fetchall(),
'pricing_profiles': conn.execute('SELECT id,name FROM pricing_profiles ORDER BY name').fetchall(),
}
@app.route('/clients/new', methods=['GET','POST'])
def new_client():
conn = get_db()
if request.method == 'POST':
def _int(k): return int(request.form[k]) if request.form.get(k) else None
conn.execute('''INSERT INTO clients
(name,email,notes,default_machine_profile_id,default_handling_profile_id,
default_material_profile_id,default_pricing_profile_id)
VALUES(?,?,?,?,?,?,?)''',
(request.form['name'], request.form.get('email',''), request.form.get('notes',''),
_int('default_machine_profile_id'), _int('default_handling_profile_id'),
_int('default_material_profile_id'), _int('default_pricing_profile_id')))
conn.commit(); conn.close()
return redirect(url_for('clients'))
profiles = _get_all_profiles(conn)
conn.close()
return render_template('client_form.html', client=None, **profiles)
@app.route('/clients/<int:id>/edit', methods=['GET','POST'])
def edit_client(id):
conn = get_db()
client = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
def _int(k): return int(request.form[k]) if request.form.get(k) else None
conn.execute('''UPDATE clients SET name=?,email=?,notes=?,
default_machine_profile_id=?,default_handling_profile_id=?,
default_material_profile_id=?,default_pricing_profile_id=? WHERE id=?''',
(request.form['name'], request.form.get('email',''), request.form.get('notes',''),
_int('default_machine_profile_id'), _int('default_handling_profile_id'),
_int('default_material_profile_id'), _int('default_pricing_profile_id'), id))
conn.commit(); conn.close()
return redirect(url_for('clients'))
profiles = _get_all_profiles(conn)
conn.close()
return render_template('client_form.html', client=client, **profiles)
@app.route('/api/client/<int:id>/defaults')
def api_client_defaults(id):
conn = get_db()
c = conn.execute('SELECT default_machine_profile_id, default_handling_profile_id, \
default_material_profile_id, default_pricing_profile_id FROM clients WHERE id=?',(id,)).fetchone()
conn.close()
if not c:
return jsonify({})
return jsonify({
'machine_profile_id': c['default_machine_profile_id'],
'handling_profile_id': c['default_handling_profile_id'],
'material_profile_id': c['default_material_profile_id'],
'pricing_profile_id': c['default_pricing_profile_id'],
})
@app.route('/clients/<int:id>/delete', methods=['POST'])
def delete_client(id):
conn = get_db()
conn.execute('DELETE FROM clients WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('clients'))
# ─── Projets ──────────────────────────────────────────────────────────────────
@app.route('/projects')
def projects():
conn = get_db()
rows = conn.execute('''
SELECT p.*, c.name as client_name,
COUNT(j.id) as job_count,
COALESCE(SUM(j.order_qty), 0) as total_pieces,
COALESCE(SUM(j.final_price * j.order_qty), 0) as total_revenue
FROM projects p
LEFT JOIN clients c ON c.id = p.client_id
LEFT JOIN jobs j ON j.project_id = p.id
GROUP BY p.id ORDER BY p.created_at DESC
''').fetchall()
conn.close()
return render_template('projects.html', projects=rows)
@app.route('/projects/new', methods=['GET','POST'])
def new_project():
conn = get_db()
if request.method == 'POST':
conn.execute('INSERT INTO projects(name,client_id,deadline,notes) VALUES(?,?,?,?)',
(request.form['name'],
int(request.form['client_id']) if request.form.get('client_id') else None,
request.form.get('deadline') or None,
request.form.get('notes','')))
conn.commit(); conn.close()
return redirect(url_for('projects'))
clients = conn.execute('SELECT id,name FROM clients ORDER BY name').fetchall()
conn.close()
return render_template('project_form.html', project=None, clients=clients)
@app.route('/projects/<int:id>', methods=['GET'])
def project_detail(id):
conn = get_db()
project = conn.execute('''
SELECT p.*, c.name as client_name
FROM projects p LEFT JOIN clients c ON c.id=p.client_id
WHERE p.id=?''', (id,)).fetchone()
if not project:
conn.close(); return redirect(url_for('projects'))
jobs = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type
FROM jobs j
LEFT JOIN clients c ON c.id=j.client_id
LEFT JOIN materials m ON m.id=j.material_id
WHERE j.project_id=?
ORDER BY j.created_at''', (id,)).fetchall()
conn.close()
total_pieces = sum((j['order_qty'] or 1) for j in jobs)
total_revenue = sum((j['final_price'] or 0) * (j['order_qty'] or 1) for j in jobs)
return render_template('project_detail.html', project=project, jobs=jobs,
total_pieces=total_pieces, total_revenue=total_revenue)
@app.route('/projects/<int:id>/edit', methods=['GET','POST'])
def edit_project(id):
conn = get_db()
project = conn.execute('SELECT * FROM projects WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('UPDATE projects SET name=?,client_id=?,deadline=?,notes=? WHERE id=?',
(request.form['name'],
int(request.form['client_id']) if request.form.get('client_id') else None,
request.form.get('deadline') or None,
request.form.get('notes',''), id))
conn.commit(); conn.close()
return redirect(url_for('project_detail', id=id))
clients = conn.execute('SELECT id,name FROM clients ORDER BY name').fetchall()
conn.close()
return render_template('project_form.html', project=project, clients=clients)
@app.route('/projects/<int:id>/delete', methods=['POST'])
def delete_project(id):
conn = get_db()
conn.execute('UPDATE jobs SET project_id=NULL WHERE project_id=?',(id,))
conn.execute('DELETE FROM projects WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('projects'))
# ─── Matières ─────────────────────────────────────────────────────────────────
@app.route('/materials')
def materials():
conn = get_db()
mats = conn.execute('''
SELECT m.*, COUNT(j.id) as job_count,
ROUND(COALESCE(SUM(j.weight_g),0),1) as total_used_g
FROM materials m LEFT JOIN jobs j ON j.material_id=m.id
GROUP BY m.id ORDER BY m.type, m.name''').fetchall()
types = sorted(set(m['type'] for m in mats if m['type']))
conn.close()
return render_template('materials.html', materials=mats, types=types)
@app.route('/materials/new', methods=['GET','POST'])
def new_material():
if request.method == 'POST':
price = float(request.form['price_per_kg'])
conn = get_db()
conn.execute('''INSERT INTO materials(name,brand,type,color,price_per_kg,stock_g,low_stock_threshold_g,notes)
VALUES(?,?,?,?,?,?,?,?)''',
(request.form['name'], request.form.get('brand',''), request.form.get('type','PLA'),
request.form.get('color',''), price, float(request.form.get('stock_g',0)),
float(request.form.get('low_stock_threshold_g',200)), request.form.get('notes','')))
mat_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0]
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (mat_id, price))
conn.commit(); conn.close()
return redirect(url_for('materials'))
return render_template('material_form.html', material=None, history=None)
@app.route('/materials/<int:id>/edit', methods=['GET','POST'])
def edit_material(id):
conn = get_db()
mat = conn.execute('SELECT * FROM materials WHERE id=?',(id,)).fetchone()
history = conn.execute(
'SELECT * FROM material_price_history WHERE material_id=? ORDER BY recorded_at DESC LIMIT 10',
(id,)).fetchall()
if request.method == 'POST':
new_price = float(request.form['price_per_kg'])
old_price = mat['price_per_kg']
conn.execute('''UPDATE materials SET name=?,brand=?,type=?,color=?,price_per_kg=?,
stock_g=?,low_stock_threshold_g=?,notes=? WHERE id=?''',
(request.form['name'], request.form.get('brand',''), request.form.get('type','PLA'),
request.form.get('color',''), new_price, float(request.form.get('stock_g', mat['stock_g'])),
float(request.form.get('low_stock_threshold_g',200)), request.form.get('notes',''), id))
if abs(new_price - old_price) > 0.001:
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (id, new_price))
conn.commit(); conn.close()
return redirect(url_for('materials'))
conn.close()
return render_template('material_form.html', material=mat, history=history)
@app.route('/materials/<int:id>/restock', methods=['POST'])
def restock_material(id):
conn = get_db()
add_g = float(request.form.get('add_g', 0))
conn.execute('UPDATE materials SET stock_g=stock_g+? WHERE id=?', (add_g, id))
new_price = request.form.get('new_price_per_kg')
if new_price:
new_price = float(new_price)
mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(id,)).fetchone()
conn.execute('UPDATE materials SET price_per_kg=? WHERE id=?', (new_price, id))
if abs(new_price - mat['price_per_kg']) > 0.001:
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)', (id, new_price))
conn.commit(); conn.close()
return redirect(url_for('materials'))
@app.route('/materials/<int:id>/duplicate', methods=['POST'])
def duplicate_material(id):
conn = get_db()
m = conn.execute('SELECT * FROM materials WHERE id=?',(id,)).fetchone()
if m:
conn.execute('''INSERT INTO materials(name,brand,type,color,price_per_kg,stock_g,low_stock_threshold_g,notes)
VALUES(?,?,?,?,?,?,?,?)''',
('Copie de ' + m['name'], m['brand'], m['type'], m['color'],
m['price_per_kg'], 0, m['low_stock_threshold_g'], m['notes']))
new_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0]
conn.execute('INSERT INTO material_price_history(material_id,price_per_kg) VALUES(?,?)',
(new_id, m['price_per_kg']))
conn.commit(); conn.close()
return redirect(url_for('edit_material', id=new_id))
conn.close()
return redirect(url_for('materials'))
@app.route('/materials/<int:id>/delete', methods=['POST'])
def delete_material(id):
conn = get_db()
conn.execute('DELETE FROM materials WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('materials'))
# ─── Profils / Templates ──────────────────────────────────────────────────────
@app.route('/profiles')
def profiles():
conn = get_db()
machine = conn.execute('SELECT * FROM machine_profiles ORDER BY name').fetchall()
handling = conn.execute('SELECT * FROM handling_profiles ORDER BY name').fetchall()
material = conn.execute('SELECT * FROM material_profiles ORDER BY name').fetchall()
pricing = conn.execute('SELECT * FROM pricing_profiles ORDER BY name').fetchall()
conn.close()
return render_template('profiles.html',
machine_profiles=machine, handling_profiles=handling,
material_profiles=material, pricing_profiles=pricing)
@app.route('/profiles/machine/new', methods=['GET','POST'])
def new_machine_profile():
if request.method == 'POST':
conn = get_db()
conn.execute('''INSERT INTO machine_profiles
(name,printer_price,printer_lifespan_hours,nozzle_price,nozzle_lifespan_hours,
plate_price,plate_lifespan_hours,printer_power_kw,electricity_price_kwh,notes)
VALUES(?,?,?,?,?,?,?,?,?,?)''', (
request.form['name'],
float(request.form['printer_price']),
float(request.form['printer_lifespan_hours']),
float(request.form['nozzle_price']),
float(request.form['nozzle_lifespan_hours']),
float(request.form['plate_price']),
float(request.form['plate_lifespan_hours']),
float(request.form['printer_power_kw']),
float(request.form['electricity_price_kwh']),
request.form.get('notes','')
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#machine')
return render_template('machine_profile_form.html', profile=None, settings=get_settings())
@app.route('/profiles/machine/<int:id>/edit', methods=['GET','POST'])
def edit_machine_profile(id):
conn = get_db()
profile = conn.execute('SELECT * FROM machine_profiles WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('''UPDATE machine_profiles SET
name=?,printer_price=?,printer_lifespan_hours=?,nozzle_price=?,nozzle_lifespan_hours=?,
plate_price=?,plate_lifespan_hours=?,printer_power_kw=?,electricity_price_kwh=?,notes=?
WHERE id=?''', (
request.form['name'],
float(request.form['printer_price']),
float(request.form['printer_lifespan_hours']),
float(request.form['nozzle_price']),
float(request.form['nozzle_lifespan_hours']),
float(request.form['plate_price']),
float(request.form['plate_lifespan_hours']),
float(request.form['printer_power_kw']),
float(request.form['electricity_price_kwh']),
request.form.get('notes',''), id
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#machine')
conn.close()
return render_template('machine_profile_form.html', profile=profile, settings=get_settings())
@app.route('/profiles/machine/<int:id>/delete', methods=['POST'])
def delete_machine_profile(id):
conn = get_db()
conn.execute('DELETE FROM machine_profiles WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#machine')
@app.route('/profiles/handling/new', methods=['GET','POST'])
def new_handling_profile():
if request.method == 'POST':
conn = get_db()
conn.execute('INSERT INTO handling_profiles(name,handling_rate_per_hour,handling_minutes_per_plate,notes) VALUES(?,?,?,?)', (
request.form['name'],
float(request.form['handling_rate_per_hour']),
float(request.form['handling_minutes_per_plate']),
request.form.get('notes','')
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#handling')
return render_template('handling_profile_form.html', profile=None, settings=get_settings())
@app.route('/profiles/handling/<int:id>/edit', methods=['GET','POST'])
def edit_handling_profile(id):
conn = get_db()
profile = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('UPDATE handling_profiles SET name=?,handling_rate_per_hour=?,handling_minutes_per_plate=?,notes=? WHERE id=?', (
request.form['name'],
float(request.form['handling_rate_per_hour']),
float(request.form['handling_minutes_per_plate']),
request.form.get('notes',''), id
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#handling')
conn.close()
return render_template('handling_profile_form.html', profile=profile, settings=get_settings())
@app.route('/profiles/handling/<int:id>/delete', methods=['POST'])
def delete_handling_profile(id):
conn = get_db()
conn.execute('DELETE FROM handling_profiles WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#handling')
@app.route('/profiles/material/new', methods=['GET','POST'])
def new_material_profile():
if request.method == 'POST':
conn = get_db()
conn.execute('INSERT INTO material_profiles(name,material_margin_pct,notes) VALUES(?,?,?)', (
request.form['name'],
float(request.form['material_margin_pct']),
request.form.get('notes','')
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#material')
return render_template('material_profile_form.html', profile=None, settings=get_settings())
@app.route('/profiles/material/<int:id>/edit', methods=['GET','POST'])
def edit_material_profile(id):
conn = get_db()
profile = conn.execute('SELECT * FROM material_profiles WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('UPDATE material_profiles SET name=?,material_margin_pct=?,notes=? WHERE id=?', (
request.form['name'],
float(request.form['material_margin_pct']),
request.form.get('notes',''), id
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#material')
conn.close()
return render_template('material_profile_form.html', profile=profile, settings=get_settings())
@app.route('/profiles/material/<int:id>/delete', methods=['POST'])
def delete_material_profile(id):
conn = get_db()
conn.execute('DELETE FROM material_profiles WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#material')
@app.route('/profiles/pricing/new', methods=['GET','POST'])
def new_pricing_profile():
if request.method == 'POST':
conn = get_db()
conn.execute('INSERT INTO pricing_profiles(name,design_multiplier,gross_margin_pct,notes) VALUES(?,?,?,?)', (
request.form['name'],
float(request.form['design_multiplier']),
float(request.form['gross_margin_pct']),
request.form.get('notes','')
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#pricing')
return render_template('pricing_profile_form.html', profile=None, settings=get_settings())
@app.route('/profiles/pricing/<int:id>/edit', methods=['GET','POST'])
def edit_pricing_profile(id):
conn = get_db()
profile = conn.execute('SELECT * FROM pricing_profiles WHERE id=?',(id,)).fetchone()
if request.method == 'POST':
conn.execute('UPDATE pricing_profiles SET name=?,design_multiplier=?,gross_margin_pct=?,notes=? WHERE id=?', (
request.form['name'],
float(request.form['design_multiplier']),
float(request.form['gross_margin_pct']),
request.form.get('notes',''), id
))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#pricing')
conn.close()
return render_template('pricing_profile_form.html', profile=profile, settings=get_settings())
@app.route('/profiles/pricing/<int:id>/delete', methods=['POST'])
def delete_pricing_profile(id):
conn = get_db()
conn.execute('DELETE FROM pricing_profiles WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('profiles') + '#pricing')
# ─── Commandes ────────────────────────────────────────────────────────────────
@app.route('/jobs')
def jobs():
conn = get_db()
jobs = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name,
ps.status as slot_status, ps.planned_start
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
LEFT JOIN print_slots ps ON ps.job_id=j.id AND ps.id=(
SELECT id FROM print_slots WHERE job_id=j.id ORDER BY planned_start LIMIT 1)
ORDER BY j.created_at DESC''').fetchall()
conn.close()
return render_template('jobs.html', jobs=jobs)
@app.route('/jobs/new', methods=['GET','POST'])
def new_job():
conn = get_db()
clients = conn.execute('SELECT * FROM clients ORDER BY name').fetchall()
mats = conn.execute('SELECT * FROM materials ORDER BY type,name').fetchall()
machine_profiles = conn.execute('SELECT * FROM machine_profiles ORDER BY name').fetchall()
handling_profiles = conn.execute('SELECT * FROM handling_profiles ORDER BY name').fetchall()
material_profiles = conn.execute('SELECT * FROM material_profiles ORDER BY name').fetchall()
pricing_profiles = conn.execute('SELECT * FROM pricing_profiles ORDER BY name').fetchall()
projects = conn.execute('SELECT id,name FROM projects ORDER BY name').fetchall()
settings = get_settings()
# Pré-remplissage depuis un job existant
prefill = None
clone_from = request.args.get('clone_from')
if clone_from:
prefill = conn.execute('SELECT * FROM jobs WHERE id=?', (clone_from,)).fetchone()
if prefill:
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'])
hours = int(request.form.get('hours',0))
minutes = int(request.form.get('minutes',0))
print_time_s = hours*3600 + minutes*60
design_mult = float(request.form['design_multiplier'])
gross_margin = float(request.form['gross_margin_pct'])
discount = float(request.form.get('discount_pct',0))
client_id = request.form.get('client_id') or None
material_id = request.form.get('material_id') or None
source_file = request.form.get('source_file','').strip()
machine_pid = request.form.get('machine_profile_id') or None
handling_pid = request.form.get('handling_profile_id') or None
material_pid = request.form.get('material_profile_id') or None
pricing_pid = request.form.get('pricing_profile_id') or None
project_id = request.form.get('project_id') or None
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:
mp = conn.execute('SELECT * FROM machine_profiles WHERE id=?',(machine_pid,)).fetchone()
if mp:
s['printer_price'] = mp['printer_price']
s['printer_lifespan_hours'] = mp['printer_lifespan_hours']
s['nozzle_price'] = mp['nozzle_price']
s['nozzle_lifespan_hours'] = mp['nozzle_lifespan_hours']
s['plate_price'] = mp['plate_price']
s['plate_lifespan_hours'] = mp['plate_lifespan_hours']
s['printer_power_kw'] = mp['printer_power_kw']
s['electricity_price_kwh'] = mp['electricity_price_kwh']
if handling_pid:
hp = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(handling_pid,)).fetchone()
if hp:
s['handling_rate_per_hour'] = hp['handling_rate_per_hour']
s['handling_minutes_per_plate'] = hp['handling_minutes_per_plate']
if material_pid:
marp = conn.execute('SELECT * FROM material_profiles WHERE id=?',(material_pid,)).fetchone()
if marp:
s['material_margin_pct'] = marp['material_margin_pct']
price_kg = None
if material_id:
mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(material_id,)).fetchone()
if mat:
price_kg = mat['price_per_kg']
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,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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(client_id,material_id,machine_pid,handling_pid,material_pid,pricing_pid,project_id,
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'],
r['wear_cost'],r['electricity_cost'],
r['cout_fixe'],r['total_marge'],r['subtotal'],r['margin_amount'],r['marge_pct_on_ht'],
r['cotisations_amount'],r['vfl_amount'],r['tva_amount'],r['other_taxes_amount'],
r['tax_amount'],r['final_price'],round(r['final_price'],2),
request.form.get('notes','')))
new_id = conn.execute('SELECT last_insert_rowid()').fetchone()[0]
if material_id:
conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?', (weight_g, material_id))
conn.commit(); conn.close()
if request.form.get('action') == 'save_and_new':
return redirect(url_for('new_job', clone_from=new_id))
return redirect(url_for('jobs'))
conn.close()
return render_template('new_job.html', clients=clients, materials=mats, settings=settings,
machine_profiles=machine_profiles, handling_profiles=handling_profiles,
material_profiles=material_profiles, pricing_profiles=pricing_profiles,
projects=projects, prefill=prefill)
@app.route('/jobs/<int:id>')
def job_detail(id):
conn = get_db()
job = conn.execute('''
SELECT j.*, c.name as client_name,
m.name as material_name, m.color as material_color, m.type as material_type,
mp.name as machine_profile_name, hp.name as handling_profile_name,
marp.name as material_profile_name, pp.name as pricing_profile_name
FROM jobs j
LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
LEFT JOIN machine_profiles mp ON j.machine_profile_id=mp.id
LEFT JOIN handling_profiles hp ON j.handling_profile_id=hp.id
LEFT JOIN material_profiles marp ON j.material_profile_id=marp.id
LEFT JOIN pricing_profiles pp ON j.pricing_profile_id=pp.id
WHERE j.id=?''', (id,)).fetchone()
slots = conn.execute('''
SELECT ps.*, p.name as printer_name, p.ha_entity_prefix,
COALESCE(ps.pieces_override, j.pieces_per_plate) as effective_pieces,
COALESCE(ps.pieces_ignored, 0) as pieces_ignored
FROM print_slots ps
LEFT JOIN printers p ON ps.printer_id=p.id
JOIN jobs j ON ps.job_id=j.id
WHERE ps.job_id=? ORDER BY ps.planned_start''', (id,)).fetchall()
conn.close()
if not job:
return redirect(url_for('jobs'))
import math as _math
total_plates_needed = _math.ceil((job['order_qty'] or 1) / max(1, job['pieces_per_plate'] or 1))
_s = get_settings()
ha_poll_interval = int(_s.get('ha_poll_interval', 60))
return render_template('job_detail.html', job=job, slots=slots,
total_plates_needed=total_plates_needed,
ha_poll_interval=ha_poll_interval)
@app.route('/jobs/<int:id>/delete', methods=['POST'])
def delete_job(id):
conn = get_db()
conn.execute('DELETE FROM jobs WHERE id=?',(id,))
conn.commit(); conn.close()
return redirect(url_for('jobs'))
# ─── Planning ─────────────────────────────────────────────────────────────────
@app.route('/planning')
def planning():
conn = get_db()
printers = [dict(r) for r in conn.execute('SELECT * FROM printers ORDER BY id').fetchall()]
unscheduled = conn.execute('''
SELECT j.*, c.name as client_name,
COALESCE((SELECT COUNT(*) FROM print_slots ps
WHERE ps.job_id=j.id
AND ps.status NOT IN ('cancelled','failed')), 0) as scheduled_plates,
(COALESCE(j.order_qty,1) + COALESCE(j.pieces_per_plate,1) - 1)
/ COALESCE(j.pieces_per_plate,1) as total_plates_needed
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
WHERE (COALESCE(j.order_qty,1) + COALESCE(j.pieces_per_plate,1) - 1)
/ COALESCE(j.pieces_per_plate,1)
> COALESCE((SELECT COUNT(*) FROM print_slots ps
WHERE ps.job_id=j.id
AND ps.status NOT IN ('cancelled','failed')), 0)
ORDER BY j.created_at DESC LIMIT 20''').fetchall()
blocks = conn.execute(
"SELECT * FROM calendar_blocks"
" WHERE COALESCE(date_end, date) >= date('now') ORDER BY date LIMIT 60"
).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,
settings=settings)
@app.route('/printers', methods=['GET','POST'])
def printers_page():
conn = get_db()
if request.method == 'POST':
name = request.form['name']
active = 1 if request.form.get('is_active') else 0
notes = request.form.get('notes','')
ha_prefix = request.form.get('ha_entity_prefix','').strip().lower()
conn.execute('INSERT INTO printers(name,is_active,notes,ha_entity_prefix) VALUES(?,?,?,?)',
(name, active, notes, ha_prefix))
conn.commit()
printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall()
conn.close()
return render_template('printers.html', printers=printers)
@app.route('/printers/<int:id>/toggle', methods=['POST'])
def toggle_printer(id):
conn = get_db()
conn.execute('UPDATE printers SET is_active=1-is_active WHERE id=?', (id,))
conn.commit(); conn.close()
return redirect(url_for('printers_page'))
@app.route('/printers/<int:id>/delete', methods=['POST'])
def delete_printer(id):
conn = get_db()
conn.execute('DELETE FROM printers WHERE id=?', (id,))
conn.commit(); conn.close()
return redirect(url_for('printers_page'))
@app.route('/printers/<int:id>/edit', methods=['POST'])
@login_required
def edit_printer(id):
name = request.form.get('name','').strip()
ha_prefix = request.form.get('ha_entity_prefix','').strip().lower()
notes = request.form.get('notes','').strip()
if name:
conn = get_db()
conn.execute('UPDATE printers SET name=?, ha_entity_prefix=?, notes=? WHERE id=?',
(name, ha_prefix, notes, id))
conn.commit(); conn.close()
return redirect(url_for('printers_page'))
@app.route('/working-schedule', methods=['GET','POST'])
def working_schedule():
if request.method == 'POST':
conn = get_db()
for dow in range(7):
mode = request.form.get(f'mode_{dow}', 'off')
tt_start = request.form.get(f'tt_start_{dow}', '10:00')
tt_end = request.form.get(f'tt_end_{dow}', '22:00')
onsite_slot1 = request.form.get(f'onsite_slot1_{dow}', '07:00')
onsite_slot2 = request.form.get(f'onsite_slot2_{dow}', '18:30')
conn.execute('''UPDATE working_schedule SET mode=?,tt_window_start=?,tt_window_end=?,
onsite_slot_1=?,onsite_slot_2=? WHERE day_of_week=?''',
(mode, tt_start, tt_end, onsite_slot1, onsite_slot2, dow))
conn.commit(); conn.close()
return redirect(url_for('working_schedule'))
conn = get_db()
schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall()
conn.close()
return render_template('working_schedule.html', schedule=schedule)
@app.route('/calendar-blocks', methods=['GET','POST'])
def calendar_blocks():
conn = get_db()
if request.method == 'POST':
date_start = request.form['date_start']
date_end = request.form.get('date_end','').strip() or None
# If date_end < date_start, ignore it
if date_end and date_end < date_start:
date_end = None
conn.execute(
'INSERT INTO calendar_blocks(date,date_end,type,printer_id,notes) VALUES(?,?,?,?,?)',
(date_start, date_end,
request.form.get('type','off'),
request.form.get('printer_id') or None,
request.form.get('notes',''))
)
conn.commit()
blocks = conn.execute("SELECT cb.*, p.name as printer_name FROM calendar_blocks cb LEFT JOIN printers p ON cb.printer_id=p.id ORDER BY cb.date DESC LIMIT 50").fetchall()
printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall()
conn.close()
return render_template('calendar_blocks.html', blocks=blocks, printers=printers)
@app.route('/calendar-blocks/<int:id>/delete', methods=['POST'])
def delete_calendar_block(id):
conn = get_db()
conn.execute('DELETE FROM calendar_blocks WHERE id=?', (id,))
conn.commit(); conn.close()
return redirect(url_for('calendar_blocks'))
# ─── Page mobile saisie rapide ────────────────────────────────────────────────
@app.route('/mobile', methods=['GET', 'POST'])
def mobile_quick():
conn = get_db()
if request.method == 'POST':
action = request.form.get('action')
if action == 'block':
conn.execute(
'INSERT INTO calendar_blocks(date,type,printer_id,notes) VALUES(?,?,?,?)',
(request.form['date'], request.form.get('type', 'off'),
request.form.get('printer_id') or None,
request.form.get('notes', '')))
conn.commit()
elif action == 'shift':
slot_id = request.form.get('slot_id')
new_start = request.form.get('new_start')
if slot_id and new_start:
slot = conn.execute('SELECT * FROM print_slots WHERE id=?', (slot_id,)).fetchone()
if slot:
try:
orig_s = datetime.fromisoformat(slot['planned_start'])
orig_e = datetime.fromisoformat(slot['planned_end'])
new_s = datetime.fromisoformat(new_start)
delta = new_s - orig_s
new_e = (orig_e + delta).isoformat()
conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?',
(new_s.isoformat(), new_e, slot_id))
conn.commit()
except Exception:
pass
elif action == 'fail':
slot_id = request.form.get('slot_id')
note = request.form.get('failure_note', '')
wasted_g = float(request.form.get('wasted_g', 0) or 0)
if slot_id:
slot = conn.execute('''
SELECT ps.weight_g as plate_g, j.material_id, j.weight_g
FROM print_slots ps JOIN jobs j ON ps.job_id=j.id
WHERE ps.id=?''', (slot_id,)).fetchone()
if slot:
if wasted_g == 0:
wasted_g = slot['weight_g'] or 0
conn.execute(
"UPDATE print_slots SET status='failed', failure_note=?, wasted_g=? WHERE id=?",
(note, wasted_g, slot_id))
if slot['material_id'] and wasted_g > 0:
conn.execute(
'UPDATE materials SET stock_g=MAX(0,stock_g-?), total_wasted_g=COALESCE(total_wasted_g,0)+? WHERE id=?',
(wasted_g, wasted_g, slot['material_id']))
conn.commit()
elif action == 'done':
slot_id = request.form.get('slot_id')
actual_g = float(request.form.get('actual_g', 0) or 0)
if slot_id:
slot = conn.execute(
'SELECT j.material_id FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?',
(slot_id,)).fetchone()
conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (slot_id,))
if slot and slot['material_id'] and actual_g > 0:
conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?',
(actual_g, slot['material_id']))
conn.commit()
conn.close()
return redirect(url_for('mobile_quick'))
printers = conn.execute('SELECT * FROM printers WHERE is_active=1 ORDER BY id').fetchall()
today = date_cls.today().isoformat()
upcoming = conn.execute('''
SELECT ps.*, j.name as job_name, p.name as printer_name,
c.name as client_name
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
WHERE ps.status='planned' AND date(ps.planned_start) >= ?
ORDER BY ps.planned_start LIMIT 20''', (today,)).fetchall()
conn.close()
return render_template('mobile.html', printers=printers,
upcoming=upcoming, today=today)
# ─── Gestion échecs impression ────────────────────────────────────────────────
@app.route('/api/slots/<int:id>/fail', methods=['POST'])
def fail_slot(id):
"""Marque un créneau comme échoué, log le gaspillage filament."""
conn = get_db()
slot = conn.execute('''
SELECT ps.*, j.weight_g, j.material_id, j.pieces_per_plate
FROM print_slots ps JOIN jobs j ON ps.job_id=j.id
WHERE ps.id=?''', (id,)).fetchone()
if not slot:
conn.close()
return jsonify({'error': 'Créneau introuvable'}), 404
note = request.json.get('note', '') if request.is_json else request.form.get('note', '')
wasted_g = float(request.json.get('wasted_g', 0) if request.is_json else request.form.get('wasted_g', 0) or 0)
# Si wasted_g non précisé, estimer = poids plateau complet
if wasted_g == 0 and slot['weight_g']:
wasted_g = slot['weight_g']
conn.execute("""UPDATE print_slots
SET status='failed', failure_note=?, wasted_g=?
WHERE id=?""", (note, wasted_g, id))
# Déduire du stock matière si lié
if slot['material_id'] and wasted_g > 0:
conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?), total_wasted_g = COALESCE(total_wasted_g,0) + ? WHERE id=?',
(wasted_g, wasted_g, slot['material_id']))
conn.commit(); conn.close()
return jsonify({'ok': True, 'wasted_g': wasted_g})
@app.route('/api/slots/<int:id>/done', methods=['POST'])
def done_slot(id):
"""Marque un créneau comme terminé avec le poids réel consommé."""
conn = get_db()
slot = conn.execute('''
SELECT ps.*, j.material_id FROM print_slots ps
JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?''', (id,)).fetchone()
if not slot:
conn.close()
return jsonify({'error': 'Créneau introuvable'}), 404
actual_g = float(request.json.get('actual_g', 0) if request.is_json else request.form.get('actual_g', 0) or 0)
conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (id,))
# Déduire le poids réel consommé du stock
if slot['material_id'] and actual_g > 0:
conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?) WHERE id=?',
(actual_g, slot['material_id']))
conn.commit(); conn.close()
return jsonify({'ok': True})
# ─── API ──────────────────────────────────────────────────────────────────────
@app.route('/api/calculate', methods=['POST'])
def api_calculate():
d = request.json
s = dict(get_settings())
conn = get_db()
if d.get('machine_profile_id'):
mp = conn.execute('SELECT * FROM machine_profiles WHERE id=?',(d['machine_profile_id'],)).fetchone()
if mp:
s['printer_price'] = mp['printer_price']
s['printer_lifespan_hours'] = mp['printer_lifespan_hours']
s['nozzle_price'] = mp['nozzle_price']
s['nozzle_lifespan_hours'] = mp['nozzle_lifespan_hours']
s['plate_price'] = mp['plate_price']
s['plate_lifespan_hours'] = mp['plate_lifespan_hours']
s['printer_power_kw'] = mp['printer_power_kw']
s['electricity_price_kwh'] = mp['electricity_price_kwh']
if d.get('handling_profile_id'):
hp = conn.execute('SELECT * FROM handling_profiles WHERE id=?',(d['handling_profile_id'],)).fetchone()
if hp:
s['handling_rate_per_hour'] = hp['handling_rate_per_hour']
s['handling_minutes_per_plate'] = hp['handling_minutes_per_plate']
if d.get('material_profile_id'):
marp = conn.execute('SELECT * FROM material_profiles WHERE id=?',(d['material_profile_id'],)).fetchone()
if marp:
s['material_margin_pct'] = marp['material_margin_pct']
price_kg = None
if d.get('material_id'):
mat = conn.execute('SELECT price_per_kg FROM materials WHERE id=?',(d['material_id'],)).fetchone()
if mat:
price_kg = mat['price_per_kg']
conn.close()
pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1)))
order_qty = max(1, int(d.get('order_qty', 1)))
print_time_s = int(d.get('hours',0))*3600 + int(d.get('minutes',0))*60
result = calc(
weight_g=float(d['weight_g']),
print_time_s=print_time_s,
design_mult=float(d.get('design_multiplier',1.80)),
gross_margin_pct=float(d.get('gross_margin_pct',27.5)),
discount_pct=float(d.get('discount_pct',0)),
price_per_kg_override=price_kg, s=s,
pieces_per_plate=pieces_per_plate)
# Multi-piece extras
nb_plateaux = math.ceil(order_qty / pieces_per_plate)
price_order = round(result['final_price'] * order_qty, 2)
last_plate_qty = order_qty % pieces_per_plate
plate_full = last_plate_qty == 0
cooldown_min = s.get('cooldown_minutes', 15)
total_print_min = (print_time_s / 60 + cooldown_min) * nb_plateaux - cooldown_min
capacity = calc_capacity(print_time_s, pieces_per_plate)
result['order_qty'] = order_qty
result['nb_plateaux'] = nb_plateaux
result['price_order'] = price_order
result['plate_full'] = plate_full
result['last_plate_qty'] = last_plate_qty
result['total_print_min']= round(total_print_min, 0)
result['capacity'] = capacity
return jsonify(result)
@app.route('/api/clients/<int:id>')
def api_client(id):
conn = get_db()
c = conn.execute('SELECT * FROM clients WHERE id=?',(id,)).fetchone()
conn.close()
if not c:
return jsonify({}), 404
return jsonify({'design_multiplier': c['design_multiplier']})
@app.route('/api/parse-3mf', methods=['POST'])
def api_parse_3mf():
if 'file' not in request.files:
return jsonify({'error': 'Aucun fichier'}), 400
f = request.files['file']
if not f.filename.lower().endswith('.3mf'):
return jsonify({'error': 'Format .3mf requis'}), 400
result = parse_3mf(f.stream)
# Nettoyer .gcode.3mf ou .3mf du nom
clean = re.sub(r'\.gcode\.3mf$', '', f.filename, flags=re.IGNORECASE)
clean = re.sub(r'\.3mf$', '', clean, flags=re.IGNORECASE)
result['filename'] = clean
# Si une seule plaque, pré-sélectionner automatiquement
if result['plates'] and len(result['plates']) == 1:
p = result['plates'][0]
result['weight_g'] = p['weight_g']
result['hours'] = p['print_time_s'] // 3600
result['minutes'] = (p['print_time_s'] % 3600) // 60
return jsonify(result)
@app.route('/api/slots')
def api_slots():
conn = get_db()
slots = conn.execute('''
SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end,
ps.status, ps.failure_note,
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.pieces_per_plate, j.order_qty, j.weight_g,
j.plate_name,
p.name as printer_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])
@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):
"""
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')
AND planned_start < ? AND planned_end > ?
AND planned_end > datetime('now')"""
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 > ?
AND ps.planned_end > datetime('now')"""
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
job_id = int(d['job_id'])
printer_id = int(d['printer_id'])
start_str = d['planned_start'] # ISO string
conn = get_db()
job = conn.execute('SELECT print_time_s FROM jobs WHERE id=?', (job_id,)).fetchone()
s = get_settings()
if not job:
conn.close()
return jsonify({'error': 'Job introuvable'}), 404
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)
already_done = bool(d.get('already_done', False))
if already_done:
# Retroactive — skip capacity/overlap checks, insert as done
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(), 'done'))
conn.commit(); conn.close()
return jsonify({'ok': True})
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()
return jsonify({'ok': True})
@app.route('/api/slots/<int:id>/move', methods=['POST'])
def api_move_slot(id):
d = request.json
start_str = d['planned_start']
conn = get_db()
slot = conn.execute('SELECT ps.*, j.print_time_s FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?', (id,)).fetchone()
if not slot:
conn.close()
return jsonify({'error': 'Slot introuvable'}), 404
s = get_settings()
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()
return jsonify({'ok': True})
@app.route('/api/slots/<int:id>/status', methods=['POST'])
def api_slot_status(id):
d = request.json
conn = get_db()
conn.execute('UPDATE print_slots SET status=?,failure_note=? WHERE id=?',
(d.get('status','planned'), d.get('note',''), id))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/api/slots/<int:id>/delete', methods=['POST'])
def api_delete_slot(id):
conn = get_db()
conn.execute('DELETE FROM print_slots WHERE id=?', (id,))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/api/suggest-slot', methods=['POST'])
def api_suggest_slot():
d = request.json
print_time_s = int(d.get('print_time_s', 0))
result = find_next_slot(print_time_s)
if result:
return jsonify(result)
return jsonify({'error': 'Aucun créneau disponible dans les 60 prochains jours'}), 404
@app.route('/api/capacity', methods=['POST'])
def api_capacity():
d = request.json
print_time_s = int(d.get('print_time_s', 0))
pieces_per_plate = max(1, int(d.get('pieces_per_plate', 1)))
result = calc_capacity(print_time_s, pieces_per_plate)
return jsonify(result)
@app.route('/api/availability')
def api_availability():
"""Returns unavailable periods for the next 4 weeks as JSON for vis.js background items."""
schedule = get_schedule_dict()
today = date_cls.today()
blocks = get_blocks_dict(today, today + timedelta(days=28))
result = []
for offset in range(28):
d = today + timedelta(days=offset)
date_str = str(d)
block = next((b for b in blocks
if b['date'] <= date_str
and (b.get('date_end') or b['date']) >= date_str
and b['printer_id'] is None), None)
mode = block['type'] if block else schedule[d.weekday()]['mode']
if mode == 'off':
result.append({
'start': date_str + 'T00:00:00',
'end': date_str + 'T23:59:59',
'type': 'off'
})
elif mode == 'tt':
sched = schedule[d.weekday()]
# Unavailable before tt_window_start
result.append({'start': date_str+'T00:00:00', 'end': date_str+'T'+sched['tt_window_start']+':00', 'type':'unavailable'})
# Unavailable after tt_window_end
result.append({'start': date_str+'T'+sched['tt_window_end']+':00', 'end': date_str+'T23:59:59', 'type':'unavailable'})
return jsonify(result)
# ─── iCal Export ──────────────────────────────────────────────────────────────
def _ical_escape(s):
return str(s).replace('\\','\\\\').replace('\n','\\n').replace(',','\\,').replace(';','\\;')
def _ical_dt(dt_str):
return dt_str.replace('-','').replace(':','').replace(' ','T').split('.')[0]
@app.route('/calendar.ics')
def calendar_ics():
# Token optionnel : si ical_token est défini dans les settings, vérifier ?token=xxx
conn = get_db()
s_row = conn.execute("SELECT value FROM settings WHERE key='ical_token'").fetchone()
ical_token = s_row['value'] if s_row else ''
if ical_token and request.args.get('token', '') != ical_token:
conn.close()
return Response('Non autorisé', status=401)
slots = conn.execute('''
SELECT ps.*, j.name as job_name, j.order_qty, j.pieces_per_plate,
j.weight_g, j.notes as job_notes,
p.name as printer_name, c.name as client_name
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
WHERE ps.status NOT IN ('cancelled')
ORDER BY ps.planned_start''').fetchall()
blocks = conn.execute('''
SELECT cb.*, p.name as printer_name
FROM calendar_blocks cb
LEFT JOIN printers p ON cb.printer_id=p.id
ORDER BY cb.date''').fetchall()
conn.close()
now_str = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//3D Pricing//Planning//FR',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Planning Impression 3D',
'X-WR-TIMEZONE:Europe/Paris',
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
]
# Créneaux d'impression
for s in slots:
start = _ical_dt(s['planned_start'])
end = _ical_dt(s['planned_end'])
client_part = f" — {s['client_name']}" if s['client_name'] else ''
desc_parts = [
f"Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau",
f"Statut: {s['status']}",
]
if s['failure_note']:
desc_parts.append(f"Note: {s['failure_note']}")
if s['job_notes']:
desc_parts.append(s['job_notes'])
lines += [
'BEGIN:VEVENT',
f"DTSTART:{start}",
f"DTEND:{end}",
f"DTSTAMP:{now_str}",
f"SUMMARY:{_ical_escape(s['job_name'] + client_part + ' [' + s['printer_name'] + ']')}",
f"DESCRIPTION:{_ical_escape(' | '.join(desc_parts))}",
f"UID:slot-{s['id']}@3d-pricing",
f"STATUS:{'CONFIRMED' if s['status']=='done' else 'TENTATIVE'}",
f"CATEGORIES:{'Terminé' if s['status']=='done' else 'Planifié'}",
'END:VEVENT',
]
# Jours indisponibles / bloqués
for b in blocks:
date_val = b['date'].replace('-', '')
# For DTEND: use date_end+1 if set, else date+1 (all-day exclusive end)
end_src = b['date_end'] if b['date_end'] else b['date']
d_obj = datetime.strptime(end_src, '%Y-%m-%d') + timedelta(days=1)
end_val = d_obj.strftime('%Y%m%d')
label_map = {'off': '🔴 Jour off', 'maintenance': '🔧 Maintenance', 'other': '📌 Bloqué'}
label = label_map.get(b['type'], b['type'])
if b['printer_name']:
label += f" — {b['printer_name']}"
notes = b['notes'] or ''
lines += [
'BEGIN:VEVENT',
f"DTSTART;VALUE=DATE:{date_val}",
f"DTEND;VALUE=DATE:{end_val}",
f"DTSTAMP:{now_str}",
f"SUMMARY:{_ical_escape(label)}",
f"DESCRIPTION:{_ical_escape(notes)}",
f"UID:block-{b['id']}@3d-pricing",
'TRANSP:TRANSPARENT',
'STATUS:CONFIRMED',
'END:VEVENT',
]
lines.append('END:VCALENDAR')
ical = '\r\n'.join(lines)
return Response(ical, mimetype='text/calendar; charset=utf-8',
headers={'Content-Disposition': 'inline; filename=planning_3d.ics'})
# ─── Home Assistant — Sync ────────────────────────────────────────────────────
def _ha_fetch_all_states(ha_url, ha_token):
"""Retourne dict {entity_id: state_str} depuis l'API HA."""
if not _requests:
return {}
headers = {'Authorization': f'Bearer {ha_token}'}
r = _requests.get(f'{ha_url}/api/states', headers=headers, timeout=15)
if r.status_code != 200:
return {}
return {s['entity_id']: s['state'] for s in r.json()}
def _ha_sync_once():
"""Poll HA et met a jour les print_slots actifs."""
try:
# Use direct connection with timeout — avoids locking conflicts with Flask request threads
_conn = sqlite3.connect(DATABASE, timeout=15)
_conn.row_factory = sqlite3.Row
try:
_s = _conn.execute("SELECT key, value FROM settings").fetchall()
finally:
_conn.close()
s = {r['key']: r['value'] for r in _s}
ha_url = str(s.get('ha_url', '')).rstrip('/')
ha_token = str(s.get('ha_token', ''))
if not ha_url or not ha_token:
return
states = _ha_fetch_all_states(ha_url, ha_token)
if not states:
return
# Lire les imprimantes avec une connexion courte (lecture seule)
_rc = sqlite3.connect(DATABASE, timeout=15)
_rc.row_factory = sqlite3.Row
printers = _rc.execute(
"SELECT id, name, ha_entity_prefix FROM printers "
"WHERE ha_entity_prefix IS NOT NULL AND ha_entity_prefix != ''"
).fetchall()
_rc.close()
now_iso = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
window = (datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=30)).isoformat()
for p in printers:
prefix = p['ha_entity_prefix']
ha_status = states.get(f'sensor.{prefix}_etat_de_l_impression', 'unknown')
ha_error = states.get(f'binary_sensor.{prefix}_erreur_d_impression', 'off')
# Connexion courte par imprimante : on lit, on écrit, on commit, on ferme
wc = sqlite3.connect(DATABASE, timeout=15, isolation_level=None)
wc.row_factory = sqlite3.Row
try:
slot = wc.execute(
"SELECT id, status FROM print_slots "
"WHERE printer_id=? AND status IN ('planned','running') "
"AND planned_start <= ? AND planned_end >= ? "
"ORDER BY planned_start DESC LIMIT 1",
(p['id'], window, now_iso)
).fetchone()
if not slot:
continue
new_status = None
if ha_error == 'on':
new_status = 'failed'
elif ha_status == 'finish':
new_status = 'done'
elif ha_status == 'running' and slot['status'] == 'planned':
new_status = 'running'
if new_status:
wc.execute('UPDATE print_slots SET status=? WHERE id=?',
(new_status, slot['id']))
if ha_ignored > 0:
wc.execute('UPDATE print_slots SET pieces_ignored=? WHERE id=?',
(ha_ignored, slot['id']))
finally:
wc.close()
except Exception as e:
print(f'[HA sync] {e}')
def _start_ha_poll():
"""Lance le thread de polling HA en arriere-plan."""
def _loop():
time.sleep(10)
while True:
_ha_sync_once()
try:
_c = sqlite3.connect(DATABASE, timeout=5)
_c.row_factory = sqlite3.Row
_interval = int(_c.execute("SELECT value FROM settings WHERE key='ha_poll_interval'").fetchone()['value'])
_c.close()
except Exception:
_interval = 60
time.sleep(max(10, _interval))
t = threading.Thread(target=_loop, daemon=True, name='ha-poll')
t.start()
@app.route('/api/ha/status')
@login_required
def api_ha_status():
"""Retourne le statut live HA pour toutes les imprimantes configurees."""
s = get_settings()
ha_url = str(s.get('ha_url', '')).rstrip('/')
ha_token = str(s.get('ha_token', ''))
if not ha_url or not ha_token:
return jsonify({'error': 'HA non configure', 'printers': {}})
try:
if not _requests:
return jsonify({'error': 'requests non installe', 'printers': {}})
headers = {'Authorization': f'Bearer {ha_token}'}
r = _requests.get(f'{ha_url}/api/states', headers=headers, timeout=10)
if r.status_code != 200:
return jsonify({'error': f'HA HTTP {r.status_code}', 'printers': {}})
raw_states = r.json()
states = {st['entity_id']: st['state'] for st in raw_states}
conn = get_db()
printers = conn.execute(
"SELECT id, name, ha_entity_prefix FROM printers "
"WHERE ha_entity_prefix IS NOT NULL AND ha_entity_prefix != ''"
).fetchall()
conn.close()
result = {}
for p in printers:
pfx = p['ha_entity_prefix']
def gs(suf, default='unknown', _p=pfx):
return states.get(f'sensor.{_p}_{suf}', default)
def gb(suf, _p=pfx):
return states.get(f'binary_sensor.{_p}_{suf}', 'off')
try: progress = int(float(gs('progression_de_l_impression', '0')))
except: progress = 0
try: layer = int(float(gs('couche_actuelle', '0')))
except: layer = 0
try: layers_total = int(float(gs('nombre_total_de_couches', '0')))
except: layers_total = 0
try: pieces_ignored = int(float(gs('objets_ignores', '0')))
except: pieces_ignored = 0
try: pieces_total = int(float(gs('objets_imprimables', '0')))
except: pieces_total = 0
result[str(p['id'])] = {
'printer_name': p['name'],
'status': gs('etat_de_l_impression'),
'progress': progress,
'task_name': gs('nom_de_la_tache'),
'heure_fin': gs('heure_de_fin'),
'layer': layer,
'layers_total': layers_total,
'error': gb('erreur_d_impression') == 'on',
'pieces_ignored': pieces_ignored,
'pieces_total': pieces_total,
}
return jsonify({'printers': result})
except Exception as e:
return jsonify({'error': str(e), 'printers': {}})
# ─── Nextcloud WebDAV ────────────────────────────────────────────────────────
import xml.etree.ElementTree as ET
import requests as _requests
from urllib.parse import quote, unquote
def _nc_session():
"""Retourne (session_requests, base_url, username) ou None si non configuré."""
s = get_settings()
url = str(s.get('nc_url', '')).rstrip('/')
user = str(s.get('nc_username', ''))
pwd = str(s.get('nc_app_password', ''))
if not url or not user or not pwd:
return None, None, None
sess = _requests.Session()
sess.auth = (user, pwd)
sess.headers['OCS-APIRequest'] = 'true'
return sess, url, user
def _dav_url(base_url, username, path):
"""Construit l'URL WebDAV pour un chemin Nextcloud."""
clean = path.lstrip('/')
return f"{base_url}/remote.php/dav/files/{quote(username)}/{quote(clean, safe='/')}"
def _parse_propfind(xml_bytes, base_url, username, current_path):
"""Parse la réponse PROPFIND et retourne liste de dicts {name, path, is_dir, size, mtime}."""
ns = {'d': 'DAV:'}
root = ET.fromstring(xml_bytes)
items = []
dav_prefix = f"/remote.php/dav/files/{username}/"
for resp in root.findall('d:response', ns):
href = resp.findtext('d:href', '', ns)
# Enlever le préfixe DAV pour obtenir le chemin relatif
rel = unquote(href).replace(f"/remote.php/dav/files/{username}", '').rstrip('/')
if not rel:
rel = '/'
# Ignorer l'entrée courante elle-même
norm_current = ('/' + current_path.strip('/')).rstrip('/')
if rel == norm_current or (rel + '/') == norm_current:
continue
propstat = resp.find('d:propstat/d:prop', ns)
is_dir = propstat.find('d:resourcetype/d:collection', ns) is not None if propstat is not None else False
size = int(propstat.findtext('d:getcontentlength', '0', ns) or 0) if propstat is not None else 0
mtime = propstat.findtext('d:getlastmodified', '', ns) if propstat is not None else ''
name = rel.split('/')[-1] or rel
# Filtrer : dossiers ou extensions 3D
# Gérer .gcode.3mf avant le split simple
name_lower = name.lower()
if name_lower.endswith('.gcode.3mf'):
ext = '3mf'
elif '.' in name:
ext = name.rsplit('.', 1)[-1].lower()
else:
ext = ''
if not is_dir and ext not in ('3mf', 'stl', 'step', 'stp', 'obj', 'gcode'):
continue
items.append({'name': name, 'path': rel, 'is_dir': is_dir,
'size': size, 'mtime': mtime, 'ext': ext if not is_dir else 'dir'})
def _mtime_ts(m):
try:
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(m).timestamp()
except Exception:
return 0
items.sort(key=lambda x: (0 if x['is_dir'] else 1, -_mtime_ts(x['mtime'])))
return items
@app.route('/api/nextcloud/browse')
def nc_browse():
path = request.args.get('path', '')
sess, base_url, username = _nc_session()
if not sess:
return jsonify({'error': 'Nextcloud non configuré'}), 503
# Si path vide, partir du nc_root_path
if not path:
s = get_settings()
path = str(s.get('nc_root_path', '/3D'))
dav_url = _dav_url(base_url, username, path)
try:
r = sess.request('PROPFIND', dav_url,
headers={'Depth': '1', 'Content-Type': 'application/xml'},
data='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop>'
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
'</d:prop></d:propfind>',
timeout=10)
if r.status_code == 404:
return jsonify({'error': f'Chemin introuvable : {path}'}), 404
if r.status_code not in (207, 200):
return jsonify({'error': f'Erreur WebDAV {r.status_code}'}), 502
items = _parse_propfind(r.content, base_url, username, path)
# Construire le breadcrumb
parts = [p for p in path.split('/') if p]
breadcrumb = [{'name': 'Nextcloud', 'path': '/'}]
for i, p in enumerate(parts):
breadcrumb.append({'name': p, 'path': '/' + '/'.join(parts[:i+1])})
return jsonify({'path': path, 'items': items, 'breadcrumb': breadcrumb})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/nextcloud/link')
def nc_link():
"""Génère un lien de partage Nextcloud pour un fichier via l'API OCS."""
path = request.args.get('path', '')
sess, base_url, username = _nc_session()
if not sess or not path:
return jsonify({'error': 'Paramètre manquant ou Nextcloud non configuré'}), 400
try:
r = sess.post(
f"{base_url}/ocs/v2.php/apps/files_sharing/api/v1/shares",
data={'path': path, 'shareType': 3, 'permissions': 1}, # 3=public link, 1=read
headers={'Accept': 'application/json'},
timeout=10)
data = r.json()
token = data.get('ocs', {}).get('data', {}).get('token', '')
if token:
return jsonify({'url': f"{base_url}/s/{token}"})
return jsonify({'error': 'Impossible de créer le lien'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Portail client ───────────────────────────────────────────────────────────
@app.route('/jobs/<int:id>/generate-token', methods=['POST'])
def generate_client_token(id):
"""Génère ou regénère le token de partage client pour une commande."""
token = secrets.token_urlsafe(20)
conn = get_db()
conn.execute('UPDATE jobs SET client_token=? WHERE id=?', (token, id))
conn.commit(); conn.close()
return jsonify({'token': token,
'url': url_for('client_portal', token=token, _external=True)})
@app.route('/jobs/<int:id>/revoke-token', methods=['POST'])
def revoke_client_token(id):
conn = get_db()
conn.execute('UPDATE jobs SET client_token=NULL WHERE id=?', (id,))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/portal/<token>')
def client_portal(token):
"""Vue publique (sans prix) pour le client."""
conn = get_db()
job = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type,
m.color as material_color
FROM jobs j
LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
WHERE j.client_token=?''', (token,)).fetchone()
if not job:
conn.close()
return render_template('portal_404.html'), 404
slots = conn.execute('''
SELECT ps.planned_start, ps.planned_end, ps.status, ps.failure_note,
p.name as printer_name
FROM print_slots ps
JOIN printers p ON ps.printer_id=p.id
WHERE ps.job_id=? ORDER BY ps.planned_start''', (job['id'],)).fetchall()
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."""
path = request.args.get('path', '')
if not path:
return Response('Paramètre path manquant', status=400)
sess, base_url, username = _nc_session()
if not sess:
return Response('Nextcloud non configuré', status=503)
dav_url = _dav_url(base_url, username, path)
try:
r = sess.get(dav_url, timeout=30, stream=True)
if r.status_code != 200:
return Response(f'Erreur WebDAV {r.status_code}', status=502)
filename = path.split('/')[-1]
ext = filename.rsplit('.', 1)[-1].lower()
mime_map = {
'stl': 'model/stl',
'3mf': 'model/3mf',
'step': 'application/step',
'stp': 'application/step',
'obj': 'model/obj',
}
mime = mime_map.get(ext, 'application/octet-stream')
return Response(r.content, mimetype=mime,
headers={'Content-Disposition': f'inline; filename="{filename}"',
'Access-Control-Allow-Origin': '*'})
except Exception as e:
return Response(str(e), status=500)
# ─── Paramètres ───────────────────────────────────────────────────────────────
@app.route('/settings', methods=['GET','POST'])
def settings():
if request.method == 'POST':
conn = get_db()
for key in request.form:
conn.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (key, request.form[key]))
conn.commit(); conn.close()
return redirect(url_for('settings'))
return render_template('settings.html', settings=get_settings())
# ─── Statistiques ─────────────────────────────────────────────────────────────
@app.route('/stats')
def stats():
conn = get_db()
by_client = conn.execute('''
SELECT COALESCE(c.name,'Sans client') as name, COUNT(j.id) as jobs,
ROUND(SUM(j.final_price),2) as revenue, ROUND(AVG(j.gross_margin_pct),1) as avg_margin
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
GROUP BY j.client_id ORDER BY revenue DESC''').fetchall()
by_month = conn.execute('''
SELECT strftime('%Y-%m',created_at) as month, COUNT(*) as jobs,
ROUND(SUM(final_price),2) as revenue
FROM jobs GROUP BY month ORDER BY month DESC LIMIT 12''').fetchall()
by_material = conn.execute('''
SELECT COALESCE(m.name,'Non defini') as name, m.type, COUNT(j.id) as jobs,
ROUND(SUM(j.weight_g),1) as total_g, ROUND(SUM(j.final_price),2) as revenue
FROM jobs j LEFT JOIN materials m ON j.material_id=m.id
GROUP BY j.material_id ORDER BY total_g DESC''').fetchall()
waste_by_material = conn.execute('''
SELECT COALESCE(m.name,'Non défini') as name, m.type,
COALESCE(m.color,'') as color,
ROUND(COALESCE(m.total_wasted_g,0),1) as wasted_g,
ROUND(COALESCE(m.stock_g,0),1) as stock_g
FROM materials m
WHERE COALESCE(m.total_wasted_g,0) > 0
ORDER BY wasted_g DESC''').fetchall()
failure_by_printer = conn.execute('''
SELECT COALESCE(p.name,'Inconnu') as name,
COUNT(ps.id) as total_slots,
SUM(CASE WHEN ps.status='failed' THEN 1 ELSE 0 END) as failed_slots,
ROUND(SUM(COALESCE(ps.wasted_g,0)),1) as wasted_g
FROM print_slots ps
LEFT JOIN printers p ON ps.printer_id=p.id
GROUP BY ps.printer_id
HAVING total_slots > 0
ORDER BY failed_slots DESC''').fetchall()
conn.close()
return render_template('stats.html', by_client=by_client, by_month=by_month,
by_material=by_material, waste_by_material=waste_by_material,
failure_by_printer=failure_by_printer)
# ─── Export CSV ───────────────────────────────────────────────────────────────
@app.route('/export/csv')
def export_csv():
conn = get_db()
jobs = conn.execute('''
SELECT j.created_at, COALESCE(c.name,'') as client,
COALESCE(m.name,'') as material, m.type as material_type,
j.source_file, j.name, j.weight_g, j.print_time_s,
j.pieces_per_plate, j.order_qty,
j.design_multiplier, j.gross_margin_pct, j.discount_pct,
j.cout_fixe, j.handling_cost, j.design_cost, j.total_marge,
j.margin_amount, j.cotisations_amount, j.vfl_amount,
j.other_taxes_amount, j.tva_amount,
j.price_per_piece, j.final_price
FROM jobs j LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id ORDER BY j.created_at DESC''').fetchall()
conn.close()
out = io.StringIO()
w = csv.writer(out, delimiter=';')
w.writerow(['Date','Client','Matiere','Type','Fichier source','Nom',
'Poids(g)','Temps(s)','Pièces/plateau','Qté commande',
'Coeff Design','Marge(%)','Remise(%)',
'Cout fixe','Manutention','Design','Total marge',
'Marge brute','Cotisations','VFL','Autres taxes','TVA',
'Prix/pièce','Prix Final'])
for j in jobs:
w.writerow(list(j))
return Response(out.getvalue(), mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=tarifs_3d.csv'})
if __name__ == '__main__':
init_db()
_start_ha_poll()
app.run(host='0.0.0.0', port=5000, debug=False)