Files
3ddruckkalkulation/app.py
Erik Thiele c42fecacb9 Mehrere Positionen pro Auftrag und Projektverwaltung
Bisher liess sich pro Rechnung nur ein Teil kalkulieren. Ein Objekt aus
mehreren Teilen musste in getrennten Rechnungen abgebildet werden.

Positionen:
- Die Kalkulation arbeitet mit einer Positionsliste statt einem
  Einzelteil. Bezeichnung, Material, Preis/kg, Druckzeit, Arbeitszeit
  und Stueckzahl gelten je Position -- verschiedene Teile nutzen oft
  unterschiedliche Filamente und brauchen unterschiedlich viel Nacharbeit.
- Drucker, Strom, Stundenlohn, Marge und Steuer bleiben auftragsweit.
- Jede Position wird eine eigene Rechnungszeile mit Menge, Einzelpreis
  und Zeilensumme. Bei vielen Positionen bricht das PDF auf weitere
  Seiten um.
- Die Auftragssumme ist die Summe der bereits gerundeten Zeilen, damit
  die Rechnung zeilenweise nachrechenbar bleibt.
- saubere_positionen() normalisiert jede Liste aus Config oder Client
  (Typen, Wertebereiche, Obergrenze 50); es bleibt immer mindestens
  eine Position bestehen.
- Alte Configs mit den Flachfeldern werden in eine einzelne Position
  migriert, die Altfelder entfernt.
- sammlePositionen() liefert null auf Seiten ohne Positionsliste, damit
  ein Tastendruck auf der Einstellungs- oder Rechnungsseite die
  Positionen nicht loescht.

Projekte:
- Benannte Kalkulationen lassen sich speichern und wieder laden,
  Ablage in druckkalkulation_projekte.yaml (ein Mapping fuer alle
  Projekte, Schluessel ist der Projektname).
- Gesichert werden nur PROJEKT_FELDER: Name, Positionen, Kundendaten,
  Rechnungsnummer, Rechnungs- und Leistungsdatum. Betriebswerte gehoeren
  bewusst nicht dazu -- sonst wuerde das Laden eines alten Projekts den
  aktuellen Stundenlohn oder das Steuerregime ueberschreiben.
- Neue Routen: GET /projekte sowie POST /projekte/{speichern,laden,loeschen}.
- Eigenes Lock, gleiche atomare Schreibtechnik wie die Config.
- Ueberschreiben, Laden bei ungespeicherten Aenderungen und Loeschen
  fragen jeweils nach.
- Der offene Autosave-Debounce wird vor dem Speichern durchgedrueckt,
  damit wirklich der aktuelle Stand ins Projekt geht.

Die beiden Teile stehen in einem Commit, weil die Aenderungen in app.py,
script.js und index.html ineinandergreifen und ein aufgetrennter
Zwischenstand nicht als solcher getestet wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:38:26 +02:00

988 lines
37 KiB
Python

"""
3D-Druck Kostenkalkulation — Flask Web-App.
Ersetzt die alte Tkinter-Version (3dDruckKalkulator.py).
Bietet eine Weboberflaeche zur Eingabe von Druckparametern,
Live-Berechnung der Kosten, PDF-Rechnung und TXT-Export.
"""
import os
import yaml
import io
import tempfile
import threading
from datetime import datetime
from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file
from fpdf import FPDF
from fpdf.enums import XPos, YPos
def image_dimensions(filepath):
"""Liest Pixelmasse aus PNG/JPEG/WebP ohne externe Libs.""" # noqa: E501
with open(filepath, 'rb') as f:
head = f.read(24)
if head[:8] == b'\x89PNG\r\n\x1a\n':
import struct
return struct.unpack('>II', head[16:24])
if head[:2] == b'\xff\xd8':
import struct
with open(filepath, 'rb') as f:
data = f.read(8192)
i = 2
while i < len(data) - 1:
if data[i] != 0xFF:
i += 1
continue
marker = data[i+1]
if marker in (0xC0, 0xC1, 0xC2):
h, w = struct.unpack('>HH', data[i+5:i+9])
return w, h
length = struct.unpack('>H', data[i+2:i+4])[0]
i += 2 + length
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
import struct
with open(filepath, 'rb') as f:
data = f.read(64)
if data[12:16] == b'VP8 ' and len(data) >= 30:
w, h = struct.unpack('<HH', data[26:30])
return (w & 0x3FFF) * 2, (h & 0x3FFF) * 2
if data[12:16] == b'VP8L' and len(data) >= 25:
bits = struct.unpack('<I', data[21:25])[0]
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
return None, None
# Zeichen, die die PDF-Kernfonts (latin-1) nicht darstellen koennen,
# aber regelmaessig aus Copy-Paste in Kunden- und Projektnamen landen.
_LATIN1_ERSATZ = {
'\u2013': '-', '\u2014': '-', '\u2012': '-', '\u2212': '-',
'\u2018': "'", '\u2019': "'", '\u201a': ',', '\u2032': "'",
'\u201c': '"', '\u201d': '"', '\u201e': '"', '\u2033': '"',
'\u2026': '...', '\u2022': '-', '\u00a0': ' ', '\u202f': ' ',
'\u20ac': 'EUR', '\u2044': '/', '\u00ad': '',
}
# Die drei Faelle unterscheiden sich rechtlich grundlegend:
# 'regel' -> Unternehmer, weist MwSt. aus
# 'kleinunternehmer' -> Unternehmer nach §19 UStG, keine MwSt., Pflichthinweis
# 'privat' -> gar kein Unternehmer (§2 UStG); weder MwSt. noch
# §19-Hinweis noch Steuernummer gehoeren auf den Beleg
STEUERREGIME = ('regel', 'kleinunternehmer', 'privat')
KLEINUNTERNEHMER_HINWEIS = (
"Gemäß § 19 UStG wird keine Umsatzsteuer berechnet."
)
PRIVAT_HINWEIS = (
"Privatverkauf. Kein Ausweis von Umsatzsteuer."
)
def mwst_satz_label(satz):
"""Formatiert den Steuersatz ohne unnoetige Nachkommastellen (19 statt 19.0)."""
return f"{satz:g}"
def as_stueckzahl(value):
"""Stueckzahl robust nach int, mindestens 1.
Leere Felder oder Kommazahlen (z.B. '' oder '1.5') haben frueher
einen ValueError und damit HTTP 500 ausgeloest.
"""
try:
zahl = int(float(value))
except (TypeError, ValueError):
return 1
return max(1, zahl)
def pdf_text(value):
"""Macht beliebigen Text fuer die latin-1-Kernfonts von fpdf sicher.
Ohne diese Umwandlung wirft fpdf beim Schreiben einen
UnicodeEncodeError und die PDF-Route antwortet mit HTTP 500.
"""
text = '' if value is None else str(value)
for zeichen, ersatz in _LATIN1_ERSATZ.items():
text = text.replace(zeichen, ersatz)
# Alles weiterhin Unkodierbare faellt auf '?' zurueck, statt zu crashen.
return text.encode('latin-1', 'replace').decode('latin-1')
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024
CONFIG_FILE = 'druckkalkulation_config.yaml'
PROJEKTE_FILE = 'druckkalkulation_projekte.yaml'
# Felder, die zu einem Projekt gehoeren. Alles andere (Drucker, Strom,
# Stundenlohn, Marge, Steuer, Firmendaten) ist werkstattweit und wird
# bewusst NICHT mitgespeichert -- sonst wuerde das Laden eines alten
# Projekts die aktuellen Betriebswerte ueberschreiben.
PROJEKT_FELDER = ('projektName', 'positionen', 'kundenName', 'kundenAdresse',
'rechnungsnummer', 'datum', 'leistungsdatum')
MAX_PROJEKTE = 500
LOGO_EXTS = ('png', 'jpg', 'webp')
# Obergrenze, damit ein fehlerhafter Client die Config nicht aufblaeht.
MAX_POSITIONEN = 50
# Eine einzelne Kalkulationsposition. Material, Arbeitszeit und Stueckzahl
# gehoeren hierher: verschiedene Teile eines Objekts nutzen oft
# unterschiedliche Filamente und brauchen unterschiedlich viel Nacharbeit.
DEFAULT_POSITION = {
'name': '',
'materialGewicht': 0,
'materialPreisProKg': 25,
'druckzeit': 0,
'arbeitszeit': 0,
'stueckzahl': 1,
}
# Felder, die frueher direkt in der Config lagen und jetzt zu einer
# Position gehoeren. Wird fuer die Migration alter Staende gebraucht.
LEGACY_POSITIONSFELDER = ('materialGewicht', 'materialPreisProKg',
'druckzeit', 'arbeitszeit', 'stueckzahl')
DEFAULT_VALUES = {
'projektName': '',
'kundenName': '',
'kundenAdresse': '',
'positionen': [dict(DEFAULT_POSITION)],
'stromverbrauch': 0.15,
'strompreis': 0.35,
'druckerAnschaffung': 500,
'druckerNutzungsdauer': 3,
'druckerBetriebsstunden': 2000,
'stundenlohn': 25,
'gewinnmarge': 20,
# 'regel' = regelbesteuert (MwSt. wird ausgewiesen),
# 'kleinunternehmer' = §19 UStG (keine MwSt., dafuer Pflichthinweis).
'steuerregime': 'regel',
'mwst_satz': 19,
'steuernummer': '',
'ust_id': '',
'mwst_ausweisen': False,
'rechnungsnummer': '',
'datum': datetime.now().strftime('%Y-%m-%d'),
'leistungsdatum': '',
'dark_mode': False,
'firmenName': '',
'firmenAdresse': '',
'telefon': '',
'email': '',
'paypal': '',
'iban': '',
'bankName': '',
'logo': '',
}
# Serialisiert Read-modify-write auf der Config-Datei. Ohne Lock koennen
# zwei parallele Autosaves (Tippen in zwei Tabs) sich gegenseitig
# ueberschreiben.
_config_lock = threading.Lock()
class ConfigError(RuntimeError):
"""Die Config-Datei existiert, ist aber nicht lesbar."""
def load_config():
"""Liest die YAML-Konfiguration von Disk. Fehlt die Datei,
werden DEFAULT_VALUES verwendet.
Ist die Datei vorhanden aber beschaedigt, wird ConfigError geworfen,
statt stillschweigend auf Defaults zurueckzufallen -- sonst wuerde
der naechste Autosave die kaputte Datei mit Defaults ueberschreiben
und alle gespeicherten Werte waeren endgueltig weg.
"""
if not os.path.exists(CONFIG_FILE):
return dict(DEFAULT_VALUES)
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
except (OSError, yaml.YAMLError) as e:
raise ConfigError(f"{CONFIG_FILE} ist nicht lesbar: {e}") from e
if not isinstance(data, dict):
if data is None:
return dict(DEFAULT_VALUES)
raise ConfigError(f"{CONFIG_FILE} enthaelt kein YAML-Mapping")
# Migration VOR dem Auffuellen mit Defaults: sonst hat setdefault
# 'steuerregime' bereits gesetzt und die Ableitung liefe ins Leere.
data = _migriere(data)
for k, v in DEFAULT_VALUES.items():
data.setdefault(k, v)
return data
def _migriere(data):
"""Bringt aeltere Config-Staende auf das aktuelle Schema.
Frueher gab es nur den Schalter 'mwst_ausweisen'. Daraus laesst sich
das Steuerregime ableiten: wer keine MwSt. auswies, rechnete faktisch
als Kleinunternehmer ab. Nur greifen, wenn 'steuerregime' wirklich
fehlt -- ein leerer Wert im Feld ist etwas anderes als ein alter Stand.
"""
if 'steuerregime' not in data:
if 'mwst_ausweisen' in data:
data['steuerregime'] = ('regel' if data['mwst_ausweisen']
else 'kleinunternehmer')
else:
data['steuerregime'] = DEFAULT_VALUES['steuerregime']
if data['steuerregime'] not in STEUERREGIME:
data['steuerregime'] = DEFAULT_VALUES['steuerregime']
# 'mwst_ausweisen' bleibt als abgeleitetes Feld erhalten, damit
# Templates und Export nicht an zwei Stellen entscheiden muessen.
data['mwst_ausweisen'] = data['steuerregime'] == 'regel'
# Positionen: frueher lag genau ein Teil flach in der Config.
if 'positionen' not in data:
alt = {k: data[k] for k in LEGACY_POSITIONSFELDER if k in data}
if alt:
alt.setdefault('name', data.get('projektName', ''))
data['positionen'] = [alt]
else:
data['positionen'] = [dict(DEFAULT_POSITION)]
data['positionen'] = saubere_positionen(data['positionen'])
# Die alten Flachfelder nicht mitschleppen -- sonst driften zwei
# Quellen fuer dieselbe Angabe auseinander.
for k in LEGACY_POSITIONSFELDER:
data.pop(k, None)
return data
def saubere_positionen(rohliste):
"""Normalisiert eine Positionsliste aus Config oder Client-JSON.
Nimmt nur bekannte Felder an, erzwingt Zahlentypen und liefert immer
mindestens eine Position -- eine leere Kalkulation waere sinnlos und
wuerde die Oberflaeche in einen Zustand ohne Eingabefelder bringen.
"""
if not isinstance(rohliste, list):
rohliste = []
sauber = []
for eintrag in rohliste:
if not isinstance(eintrag, dict):
continue
pos = dict(DEFAULT_POSITION)
pos['name'] = str(eintrag.get('name', '') or '')[:200]
for feld in ('materialGewicht', 'materialPreisProKg',
'druckzeit', 'arbeitszeit'):
try:
wert = float(eintrag.get(feld, DEFAULT_POSITION[feld]))
except (TypeError, ValueError):
wert = float(DEFAULT_POSITION[feld])
pos[feld] = max(0.0, wert)
pos['stueckzahl'] = as_stueckzahl(eintrag.get('stueckzahl', 1))
sauber.append(pos)
if len(sauber) >= MAX_POSITIONEN:
break
return sauber or [dict(DEFAULT_POSITION)]
def save_config(data):
"""Schreibt das uebergebene Dict atomar als YAML-Datei.
Erst in eine temporaere Datei im selben Verzeichnis schreiben, dann
os.replace() -- so bleibt bei einem Absturz mitten im Schreiben die
alte Config intakt statt abgeschnitten zurueckzubleiben.
"""
verzeichnis = os.path.dirname(os.path.abspath(CONFIG_FILE))
fd, tmp_pfad = tempfile.mkstemp(
prefix='.druckkalkulation_', suffix='.tmp', dir=verzeichnis)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_pfad, CONFIG_FILE)
except Exception:
if os.path.exists(tmp_pfad):
os.remove(tmp_pfad)
raise
_projekte_lock = threading.Lock()
def lade_projekte():
"""Liest die Projektdatei. Fehlt sie, gibt es eben noch keine Projekte."""
if not os.path.exists(PROJEKTE_FILE):
return {}
try:
with open(PROJEKTE_FILE, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
except (OSError, yaml.YAMLError) as e:
raise ConfigError(f"{PROJEKTE_FILE} ist nicht lesbar: {e}") from e
if data is None:
return {}
if not isinstance(data, dict):
raise ConfigError(f"{PROJEKTE_FILE} enthaelt kein YAML-Mapping")
return data
def speichere_projekte(projekte):
"""Schreibt die Projektdatei atomar (gleiche Technik wie save_config)."""
verzeichnis = os.path.dirname(os.path.abspath(PROJEKTE_FILE))
fd, tmp_pfad = tempfile.mkstemp(
prefix='.druckkalkulation_projekte_', suffix='.tmp', dir=verzeichnis)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
yaml.dump(projekte, f, default_flow_style=False, allow_unicode=True)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_pfad, PROJEKTE_FILE)
except Exception:
if os.path.exists(tmp_pfad):
os.remove(tmp_pfad)
raise
def projekt_name(roh):
"""Normalisiert einen Projektnamen zum Schluessel.
Der Name ist der Schluessel -- er landet aber nie in einem Dateipfad,
daher reicht Trimmen und Laengenbegrenzung.
"""
return str(roh or '').strip()[:200]
def projekt_aus_config(config):
"""Schneidet die Projektfelder aus dem aktuellen Arbeitsstand heraus."""
return {feld: config.get(feld, DEFAULT_VALUES.get(feld))
for feld in PROJEKT_FELDER}
def steuer_kontext(data):
"""Liest Regime und MwSt.-Satz robust aus den Formularwerten."""
regime = str(data.get('steuerregime', 'regel')).lower()
if regime not in STEUERREGIME:
regime = 'regel'
mwst_ausweisen = regime == 'regel'
try:
mwst_satz = float(data.get('mwst_satz', 19))
except (TypeError, ValueError):
mwst_satz = 19.0
mwst_satz = max(0.0, mwst_satz)
return regime, mwst_ausweisen, mwst_satz, (mwst_satz / 100 if mwst_ausweisen else 0)
def positions_label(pos, nummer, projekt, anzahl):
"""Beschriftung einer Rechnungszeile.
Bei einer einzelnen namenlosen Position bleibt es beim bisherigen
"3D-Druck: <Projekt>". Sobald mehrere Positionen im Spiel sind,
braucht jede Zeile eine eigene, unterscheidbare Bezeichnung.
"""
name = (pos.get('name') or '').strip()
if name:
return f"3D-Druck: {name}"
if anzahl == 1:
return f"3D-Druck: {projekt}" if projekt else "3D-Druck"
return f"3D-Druck: Position {nummer}"
def berechne_position(pos, data, abschreibung_pro_stunde):
"""Berechnet eine einzelne Position.
'data' liefert die auftragsweiten Werte (Strom, Stundenlohn, Marge),
'pos' die positionsspezifischen (Material, Zeiten, Stueckzahl).
"""
druckzeit_stunden = float(pos.get('druckzeit', 0)) / 60
arbeitszeit_stunden = float(pos.get('arbeitszeit', 0)) / 60
material_kg = float(pos.get('materialGewicht', 0)) / 1000
material_preis_pro_kg = float(pos.get('materialPreisProKg', 0))
materialkosten = material_kg * material_preis_pro_kg
stromkosten = (druckzeit_stunden * float(data.get('stromverbrauch', 0))
* float(data.get('strompreis', 0)))
abschreibung = abschreibung_pro_stunde * druckzeit_stunden
arbeitskosten = arbeitszeit_stunden * float(data.get('stundenlohn', 0))
kosten = materialkosten + stromkosten + abschreibung + arbeitskosten
gewinn = kosten * (float(data.get('gewinnmarge', 0)) / 100)
verkaufspreis = kosten + gewinn
# Auf Cent gerundeter Stueckpreis ist die Basis der Zeilensumme,
# damit die Rechnungszeile nachrechenbar bleibt.
stueckzahl = as_stueckzahl(pos.get('stueckzahl', 1))
stueckpreis = round(verkaufspreis, 2)
zeilensumme = round(stueckpreis * stueckzahl, 2)
return {
'name': str(pos.get('name', '') or ''),
'stueckzahl': stueckzahl,
'materialkosten': round(materialkosten, 2),
'stromkosten': round(stromkosten, 2),
'abschreibung': round(abschreibung, 2),
'arbeitskosten': round(arbeitskosten, 2),
'kosten': round(kosten, 2),
'gewinn': round(gewinn, 2),
'stueckpreis': stueckpreis,
'zeilensumme': zeilensumme,
'druckzeit_stunden': round(druckzeit_stunden, 2),
'arbeitszeit_stunden': round(arbeitszeit_stunden, 2),
'material_kg': round(material_kg, 3),
'kosten_pro_gramm': round(material_preis_pro_kg / 1000, 3),
}
def calculate(data):
"""Berechnet alle Positionen und die Auftragssummen.
Rueckgabe: Dict mit der Positionsliste, den Summen und den
auftragsweiten Detailwerten.
"""
anschaffung = float(data.get('druckerAnschaffung', 0))
nutzung = float(data.get('druckerNutzungsdauer', 1))
betrieb = float(data.get('druckerBetriebsstunden', 1))
abschreibung_pro_stunde = (anschaffung / (nutzung * betrieb)
if (nutzung * betrieb) > 0 else 0)
positionen = saubere_positionen(data.get('positionen'))
berechnet = [berechne_position(p, data, abschreibung_pro_stunde)
for p in positionen]
regime, mwst_ausweisen, mwst_satz, mwst_faktor = steuer_kontext(data)
# Summe der bereits gerundeten Zeilen -- so stimmt die Rechnung
# auch dann, wenn der Kunde sie Zeile fuer Zeile nachrechnet.
netto = round(sum(p['zeilensumme'] for p in berechnet), 2)
mwst = round(netto * mwst_faktor, 2)
brutto = round(netto + mwst, 2)
return {
'positionen': berechnet,
'anzahl_positionen': len(berechnet),
'teile_gesamt': sum(p['stueckzahl'] for p in berechnet),
'gesamt_netto': netto,
'gesamt_mwst': mwst,
'gesamt_endpreis': brutto,
# Auftragsweite Kostensummen (fuer die Aufstellung im Frontend)
'summe_materialkosten': round(sum(p['materialkosten'] * p['stueckzahl'] for p in berechnet), 2),
'summe_stromkosten': round(sum(p['stromkosten'] * p['stueckzahl'] for p in berechnet), 2),
'summe_abschreibung': round(sum(p['abschreibung'] * p['stueckzahl'] for p in berechnet), 2),
'summe_arbeitskosten': round(sum(p['arbeitskosten'] * p['stueckzahl'] for p in berechnet), 2),
'summe_kosten': round(sum(p['kosten'] * p['stueckzahl'] for p in berechnet), 2),
'summe_gewinn': round(sum(p['gewinn'] * p['stueckzahl'] for p in berechnet), 2),
'gewinnmarge': float(data.get('gewinnmarge', 0)),
'steuerregime': regime,
'mwst_satz': mwst_satz,
'mwst_ausweisen': mwst_ausweisen,
'kleinunternehmer': regime == 'kleinunternehmer',
'privat': regime == 'privat',
# Steuernummer/USt-IdNr. gehoeren nur auf einen Unternehmer-Beleg.
'ist_unternehmer': regime != 'privat',
'druckzeit_stunden': round(sum(p['druckzeit_stunden'] * p['stueckzahl'] for p in berechnet), 2),
'material_kg': round(sum(p['material_kg'] * p['stueckzahl'] for p in berechnet), 3),
'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4),
'strom_pro_stunde': round(float(data.get('stromverbrauch', 0))
* float(data.get('strompreis', 0)), 3),
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.context_processor
def inject_konstanten():
"""Stellt den §19-Hinweis allen Templates zur Verfuegung."""
return {'kleinunternehmer_hinweis': KLEINUNTERNEHMER_HINWEIS,
'privat_hinweis': PRIVAT_HINWEIS}
@app.errorhandler(ConfigError)
def handle_config_error(e):
"""Kaputte Config-Datei: klar melden statt Daten zu ueberschreiben."""
hinweis = (f"Konfigurationsdatei beschaedigt: {e}. "
f"Bitte {CONFIG_FILE} reparieren oder loeschen "
f"(dann startet die App mit Standardwerten).")
if request.path in ('/calculate', '/save_config'):
return jsonify({'error': hinweis}), 500
return hinweis, 500
@app.route('/')
def index():
"""Hauptseite mit Eingabeformular und Ergebnissen."""
config = load_config()
calc = calculate(config)
return render_template('index.html', config=config, calc=calc)
@app.route('/settings')
def settings():
"""Einstellungsseite fuer Druckerabschreibung, Arbeitskosten und Marge."""
config = load_config()
return render_template('settings.html', config=config)
@app.route('/calculate', methods=['POST'])
def calc_json():
"""AJAX-Endpunkt: Nimmt Formular-JSON entgegen, gibt Berechnung zurueck.
Fehlende Werte werden aus der gespeicherten Config ergaenzt."""
data = request.get_json()
config = load_config()
merged = {**config, **data}
calc = calculate(merged)
return jsonify(calc)
@app.route('/save_config', methods=['POST'])
def save_config_route():
"""AJAX-Endpunkt: Speichert aktuelle Formularwerte in YAML.
Vorhandene Werte (z.B. aus Einstellungen) werden nicht ueberschrieben."""
data = request.get_json(silent=True) or {}
# Nur bekannte Schluessel uebernehmen -- sonst kann ein beliebiger
# Client die Config mit fremden Feldern vollschreiben.
gefiltert = {k: v for k, v in data.items() if k in DEFAULT_VALUES}
# Positionen sind eine Liste von Dicts -- vor dem Speichern
# normalisieren, damit kein Client-Muell in der Config landet.
if 'positionen' in gefiltert:
gefiltert['positionen'] = saubere_positionen(gefiltert['positionen'])
with _config_lock:
existing = load_config()
existing.update(gefiltert)
# Abgeleitete Felder neu berechnen, damit auf der Platte nie
# 'kleinunternehmer' neben 'mwst_ausweisen: true' steht.
existing = _migriere(existing)
save_config(existing)
return jsonify({'status': 'ok', 'gespeichert': len(gefiltert)})
@app.route('/projekte')
def projekte_liste():
"""Liefert die gespeicherten Projekte als JSON, neueste zuerst."""
projekte = lade_projekte()
config = load_config()
eintraege = []
for name, daten in projekte.items():
if not isinstance(daten, dict):
continue
positionen = saubere_positionen(daten.get('positionen'))
# Preis mit den AKTUELLEN Betriebswerten rechnen, nicht mit den
# Werten von damals -- die Marge liegt bewusst nicht im Projekt.
calc = calculate({**config, 'positionen': positionen})
eintraege.append({
'name': name,
'geaendert': daten.get('geaendert', ''),
'kundenName': daten.get('kundenName', ''),
'anzahl_positionen': calc['anzahl_positionen'],
'teile_gesamt': calc['teile_gesamt'],
'gesamt_endpreis': calc['gesamt_endpreis'],
})
eintraege.sort(key=lambda e: e['geaendert'], reverse=True)
return jsonify({'projekte': eintraege, 'aktuell': config.get('projektName', '')})
@app.route('/projekte/speichern', methods=['POST'])
def projekt_speichern():
"""Sichert den aktuellen Arbeitsstand unter seinem Projektnamen."""
daten = request.get_json(silent=True) or {}
with _config_lock:
config = load_config()
name = projekt_name(daten.get('name') or config.get('projektName'))
if not name:
return jsonify({'error': 'Bitte zuerst einen Projektnamen vergeben'}), 400
with _projekte_lock:
projekte = lade_projekte()
neu_angelegt = name not in projekte
if neu_angelegt and len(projekte) >= MAX_PROJEKTE:
return jsonify({'error': f'Maximal {MAX_PROJEKTE} Projekte'}), 400
eintrag = projekt_aus_config(config)
eintrag['projektName'] = name
eintrag['geaendert'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
projekte[name] = eintrag
speichere_projekte(projekte)
# Der Arbeitsstand traegt danach denselben Namen wie das Projekt.
with _config_lock:
config = load_config()
config['projektName'] = name
save_config(_migriere(config))
return jsonify({'status': 'ok', 'name': name, 'neu': neu_angelegt})
@app.route('/projekte/laden', methods=['POST'])
def projekt_laden():
"""Laedt ein gespeichertes Projekt in den Arbeitsstand."""
daten = request.get_json(silent=True) or {}
name = projekt_name(daten.get('name'))
with _projekte_lock:
projekte = lade_projekte()
eintrag = projekte.get(name)
if not isinstance(eintrag, dict):
return jsonify({'error': f'Projekt "{name}" nicht gefunden'}), 404
with _config_lock:
config = load_config()
for feld in PROJEKT_FELDER:
if feld in eintrag:
config[feld] = eintrag[feld]
config['projektName'] = name
save_config(_migriere(config))
return jsonify({'status': 'ok', 'name': name})
@app.route('/projekte/loeschen', methods=['POST'])
def projekt_loeschen():
"""Entfernt ein gespeichertes Projekt. Der Arbeitsstand bleibt."""
daten = request.get_json(silent=True) or {}
name = projekt_name(daten.get('name'))
with _projekte_lock:
projekte = lade_projekte()
if name not in projekte:
return jsonify({'error': f'Projekt "{name}" nicht gefunden'}), 404
del projekte[name]
speichere_projekte(projekte)
return jsonify({'status': 'ok', 'name': name})
@app.route('/upload_logo', methods=['POST'])
def upload_logo():
"""Logo-Datei hochladen, auf gueltiges Bild pruefen und speichern."""
if 'logo' not in request.files:
return jsonify({'error': 'Keine Datei'}), 400
file = request.files['logo']
if file.filename == '':
return jsonify({'error': 'Keine Datei ausgewaehlt'}), 400
raw = file.read()
# Magic-Bytes-Pruefung
if raw[:8] == b'\x89PNG\r\n\x1a\n':
ext = 'png'
elif raw[:2] in (b'\xff\xd8',):
ext = 'jpg'
elif raw[:4] == b'RIFF' and raw[8:12] == b'WEBP':
ext = 'webp'
else:
return jsonify({'error': 'Nur PNG, JPEG oder WebP erlaubt'}), 400
filename = f'logo.{ext}'
filepath = os.path.join('static', filename)
with open(filepath, 'wb') as f:
f.write(raw)
# Logos in anderen Formaten entfernen, sonst bleibt z.B. logo.png
# liegen, wenn jetzt logo.jpg aktiv ist.
for alt_ext in LOGO_EXTS:
if alt_ext == ext:
continue
alt_pfad = os.path.join('static', f'logo.{alt_ext}')
if os.path.exists(alt_pfad):
os.remove(alt_pfad)
with _config_lock:
config = load_config()
config['logo'] = filename
save_config(config)
return jsonify({'status': 'ok', 'filename': filename})
@app.route('/delete_logo', methods=['POST'])
def delete_logo():
"""Logo loeschen."""
with _config_lock:
config = load_config()
# Alle bekannten Logo-Varianten entfernen, nicht nur die
# gerade in der Config eingetragene.
for ext in LOGO_EXTS:
logo_path = os.path.join('static', f'logo.{ext}')
if os.path.exists(logo_path):
os.remove(logo_path)
config['logo'] = ''
save_config(config)
return jsonify({'status': 'ok'})
@app.route('/invoice')
def invoice():
"""Rechnungsseite mit Kundendaten und Vorschau."""
config = load_config()
calc = calculate(config)
return render_template('invoice.html', config=config, calc=calc)
@app.route('/invoice/pdf', methods=['POST'])
def invoice_pdf():
"""Generiert eine PDF-Rechnung via fpdf2 und sendet sie zum Download."""
data = request.form
config = load_config()
calc = calculate(config)
gesamt_netto = calc['gesamt_netto']
gesamt_mwst = calc['gesamt_mwst']
gesamt_brutto = calc['gesamt_endpreis']
firmen_name = pdf_text(config.get('firmenName', ''))
firmen_adresse = pdf_text(config.get('firmenAdresse', ''))
telefon = pdf_text(config.get('telefon', ''))
email = pdf_text(config.get('email', ''))
paypal = pdf_text(config.get('paypal', ''))
iban = pdf_text(config.get('iban', ''))
bank_name = pdf_text(config.get('bankName', ''))
steuernummer = pdf_text(config.get('steuernummer', ''))
ust_id = pdf_text(config.get('ust_id', ''))
kunde = pdf_text(data.get('kundenName', config.get('kundenName', '')))
adresse = pdf_text(data.get('kundenAdresse', config.get('kundenAdresse', '')))
rechnungsnummer = pdf_text(data.get('rechnungsnummer', config.get('rechnungsnummer', '')))
datum = pdf_text(data.get('datum', config.get('datum', '')))
# §14 UStG verlangt den Leistungszeitpunkt. Fehlt er, gilt das
# Rechnungsdatum -- dann ist der uebliche Zusatz noetig.
leistungsdatum = pdf_text(data.get('leistungsdatum',
config.get('leistungsdatum', '')))
projekt = pdf_text(config.get('projektName', ''))
pdf = FPDF()
pdf.set_margins(15, 10, 15)
pdf.add_page()
left = 15
col_w = 45
# Logo rechts oben
logo_name = config.get('logo', '')
logo_path = os.path.join('static', logo_name) if logo_name else ''
if logo_name and os.path.exists(logo_path):
pdf.image(logo_path, x=175, y=12, w=20)
img_w, img_h = image_dimensions(logo_path)
if img_w and img_h:
logo_bottom = 12 + 20 * (img_h / img_w) + 3
else:
logo_bottom = 12 + 12 + 3
else:
pdf.set_font("Helvetica", "B", 18)
pdf.set_xy(150, 12)
pdf.set_text_color(6, 111, 209)
pdf.cell(col_w, 7, "JET", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
pdf.set_x(150)
pdf.cell(col_w, 7, "Design", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
pdf.set_text_color(0, 0, 0)
logo_bottom = pdf.get_y()
# Firmendaten rechts unter Logo (fester Abstand)
right_x = 150
pdf.set_font("Helvetica", "B", 9)
pdf.set_xy(right_x, logo_bottom)
pdf.cell(col_w, 4, firmen_name, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
pdf.set_font("Helvetica", "", 8)
if firmen_adresse:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, firmen_adresse, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
if telefon:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, f"Tel: {telefon}", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
if email:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, email, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
if paypal:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, f"PayPal: {paypal}", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
if iban:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, iban, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
if bank_name:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, bank_name, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
# Bei Privatverkauf gehoeren Steuernummer/USt-IdNr. nicht auf den Beleg --
# sie wuerden eine Unternehmereigenschaft behaupten, die nicht besteht.
if calc.get('ist_unternehmer'):
if steuernummer:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, f"St.-Nr.: {steuernummer}",
new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
if ust_id:
pdf.set_x(right_x)
pdf.cell(col_w, 3.5, f"USt-IdNr.: {ust_id}",
new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
right_bottom = pdf.get_y()
# --- Rechnungsdaten & Empfaenger (links, unterhalb der rechten Spalte) ---
content_y = max(right_bottom + 15, 80)
pdf.set_xy(left, content_y)
pdf.set_font("Helvetica", "", 10)
if rechnungsnummer:
pdf.cell(80, 5, f"Rechnungsnr.: {rechnungsnummer}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.set_x(left)
pdf.cell(80, 5, f"Rechnungsdatum: {datum}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.set_x(left)
if leistungsdatum:
pdf.cell(80, 5, f"Leistungsdatum: {leistungsdatum}",
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
elif calc.get('ist_unternehmer'):
# Nur Unternehmer brauchen den Leistungszeitpunkt (§14 UStG).
pdf.cell(80, 5, pdf_text("Leistungsdatum entspricht dem Rechnungsdatum."),
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.set_xy(left, pdf.get_y() + 8)
pdf.set_font("Helvetica", "B", 10)
pdf.cell(80, 5, pdf_text("Rechnungsempfänger:"), new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.set_font("Helvetica", "", 10)
pdf.set_x(left)
pdf.cell(80, 5, kunde, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
if adresse:
pdf.set_x(left)
pdf.set_font("Helvetica", "", 9)
for line in adresse.split('\n'):
pdf.set_x(left)
pdf.cell(80, 4, line.strip(), new_x=XPos.LMARGIN, new_y=YPos.NEXT)
# --- Trennlinie ---
pdf.set_draw_color(220, 220, 220)
pdf.line(left, pdf.get_y() + 6, 195, pdf.get_y() + 6)
pdf.set_draw_color(0, 0, 0)
# --- Positionstabelle ---
table_y = pdf.get_y() + 10
pdf.set_xy(left, table_y)
pdf.set_font("Helvetica", "B", 10)
pdf.cell(85, 6, "Leistungsbeschreibung")
pdf.cell(20, 6, "Menge", align='R')
pdf.cell(30, 6, "Einzelpreis", align='R')
pdf.cell(30, 6, "Gesamt", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
pdf.set_draw_color(200, 200, 200)
pdf.line(left, pdf.get_y() + 0.5, 195, pdf.get_y() + 0.5)
pdf.set_draw_color(0, 0, 0)
pdf.set_font("Helvetica", "", 10)
for nummer, pos in enumerate(calc['positionen'], start=1):
# Seitenumbruch, bevor die Zeile in den Fussbereich laufen wuerde.
if pdf.get_y() > 250:
pdf.add_page()
pdf.set_y(20)
pdf.set_x(left)
pdf.cell(85, 7, pdf_text(positions_label(pos, nummer, projekt,
len(calc['positionen']))))
pdf.cell(20, 7, str(pos['stueckzahl']), align='R')
pdf.cell(30, 7, f"{pos['stueckpreis']:.2f} EUR", align='R')
pdf.cell(30, 7, f"{pos['zeilensumme']:.2f} EUR",
new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
pdf.line(left, pdf.get_y() + 0.5, 195, pdf.get_y() + 0.5)
# --- Summen (linksbundig) ---
pdf.set_x(left)
pdf.ln(8)
pdf.set_font("Helvetica", "", 10)
pdf.cell(180, 6, f"Nettobetrag: {gesamt_netto:.2f} EUR", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
if calc.get('mwst_ausweisen'):
satz = mwst_satz_label(calc['mwst_satz'])
pdf.cell(180, 6, f"MwSt. ({satz}%): {gesamt_mwst:.2f} EUR",
new_x=XPos.LMARGIN, new_y=YPos.NEXT)
pdf.set_font("Helvetica", "B", 12)
label_total = "GESAMTBETRAG (brutto):" if calc.get('mwst_ausweisen') else "GESAMTBETRAG:"
pdf.cell(180, 8, f"{label_total} {gesamt_brutto:.2f} EUR", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
# --- Steuerhinweis je nach Regime ---
steuer_hinweis = ''
if calc.get('kleinunternehmer'):
steuer_hinweis = KLEINUNTERNEHMER_HINWEIS
elif calc.get('privat'):
steuer_hinweis = PRIVAT_HINWEIS
if steuer_hinweis:
pdf.set_font("Helvetica", "", 9)
pdf.ln(4)
pdf.multi_cell(180, 4, pdf_text(steuer_hinweis))
# --- Zahlungsbedingungen ---
pdf.set_font("Helvetica", "", 9)
pdf.ln(6)
pdf.cell(180, 4, pdf_text("Zahlbar innerhalb von 14 Tagen ohne Abzug. Vielen Dank für Ihren Auftrag!"))
return send_file(
io.BytesIO(bytes(pdf.output())),
mimetype='application/pdf',
as_attachment=True,
download_name='rechnung.pdf'
)
@app.route('/export', methods=['POST'])
def export():
"""Exportiert die Kalkulation als TXT-Datei."""
data = request.get_json(silent=True) or {}
config = load_config()
merged = {**config, **data}
calc = calculate(merged)
lines = [
"3D-DRUCK KOSTENKALKULATION",
"=" * 58 + "",
"",
f"Projekt: {merged.get('projektName', '')}",
f"Datum: {merged.get('datum', '')}",
"",
]
for nummer, pos in enumerate(calc['positionen'], start=1):
label = positions_label(pos, nummer, merged.get('projektName', ''),
calc['anzahl_positionen'])
lines.append(f"POSITION {nummer}: {label}")
lines.append("-" * 58)
lines.append(f" Materialkosten: {pos['materialkosten']:>10.2f} EUR")
lines.append(f" Stromkosten: {pos['stromkosten']:>10.2f} EUR")
lines.append(f" Abschreibung: {pos['abschreibung']:>10.2f} EUR")
lines.append(f" Arbeitskosten: {pos['arbeitskosten']:>10.2f} EUR")
lines.append(f" Kosten je Stuck: {pos['kosten']:>10.2f} EUR")
lines.append(f" Gewinn je Stuck: {pos['gewinn']:>10.2f} EUR")
lines.append(f" Stueckpreis (netto): {pos['stueckpreis']:>10.2f} EUR")
lines.append(f" Stueckzahl: {pos['stueckzahl']:>10}")
lines.append(f" Zeilensumme: {pos['zeilensumme']:>10.2f} EUR")
lines.append("")
satz = mwst_satz_label(calc['mwst_satz'])
lines.append("=" * 58)
lines.append(f"AUFTRAG ({calc['anzahl_positionen']} Positionen, "
f"{calc['teile_gesamt']} Teile)")
lines.append(f" Materialkosten gesamt: {calc['summe_materialkosten']:>10.2f} EUR")
lines.append(f" Stromkosten gesamt: {calc['summe_stromkosten']:>10.2f} EUR")
lines.append(f" Abschreibung gesamt: {calc['summe_abschreibung']:>10.2f} EUR")
lines.append(f" Arbeitskosten gesamt: {calc['summe_arbeitskosten']:>10.2f} EUR")
lines.append(f" Kosten gesamt: {calc['summe_kosten']:>10.2f} EUR")
lines.append(f" Gewinn gesamt: {calc['summe_gewinn']:>10.2f} EUR")
lines.append("-" * 58)
lines.append(f" Auftrag netto: {calc['gesamt_netto']:>10.2f} EUR")
if calc.get('mwst_ausweisen'):
lines.append(f" MwSt. ({satz}%):{'':<{max(1, 16 - len(satz))}}{calc['gesamt_mwst']:>10.2f} EUR")
lines.append(f" GESAMTPREIS: {calc['gesamt_endpreis']:>10.2f} EUR")
if calc.get('kleinunternehmer'):
lines.append("")
lines.append(KLEINUNTERNEHMER_HINWEIS)
elif calc.get('privat'):
lines.append("")
lines.append(PRIVAT_HINWEIS)
text = '\n'.join(lines)
return send_file(
io.BytesIO(text.encode('utf-8')),
mimetype='text/plain',
as_attachment=True,
download_name='kalkulation.txt'
)
def _env_flag(name, default=False):
"""Liest ein Boolean aus einer Umgebungsvariable."""
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in ('1', 'true', 'yes', 'on')
if __name__ == '__main__':
# Debug-Modus NIE als Default: der Werkzeug-Debugger erlaubt
# Codeausfuehrung ueber das Netz. Lokal per FLASK_DEBUG=1 einschalten.
debug = _env_flag('FLASK_DEBUG', False)
host = os.environ.get('HOST', '0.0.0.0')
port = int(os.environ.get('PORT', '5001'))
if debug and host == '0.0.0.0':
# Im Debug-Modus nur lokal lauschen.
host = '127.0.0.1'
app.run(host=host, port=port, debug=debug)