Files
3ddruckkalkulation/app.py
Erik Thiele ab6d02eccb Fix: Ausstehender Autosave ueberschrieb das geladene Projekt
Beim Laden eines Projekts blieb der alte Projektname stehen, wenn kurz
zuvor getippt wurde.

Ursache: projektLaden() brach den laufenden Autosave-Timer nicht ab.
Der feuerte 500 ms nach dem letzten Tastendruck, las die noch alten
Formularfelder aus und schrieb sie ueber den gerade geladenen Stand.
Anschliessend folgte ein location.reload(), der den ueberschriebenen
Stand anzeigte. Auf localhost war das Zeitfenster meist zu klein, ueber
LAN gegen den Container dagegen zuverlaessig zu treffen.

- projektLaden() und projektNeu() brechen saveTimeout und calcTimeout ab.
- /projekte/laden und /projekte/neu liefern den neuen Stand zurueck,
  das Frontend setzt die Felder direkt (neue Funktion standUebernehmen).
- Kein location.reload() mehr. Das beseitigt zusaetzlich die
  Formular-Wiederherstellung, mit der Firefox und Safari von Hand
  geaenderte Felder nach einem Reload zurueckholen -- betroffen waere
  genau das Projektnamen-Feld gewesen.

Reproduziert und gegengeprueft: mit der alten Reihenfolge schreibt der
Autosave "Projekt B" ueber das geladene "Projekt A"; mit dem Fix bleiben
Feld und Serverstand ueber mehrere Wechsel hinweg stabil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:10:50 +02:00

1065 lines
40 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."
)
# Ein Zahlungsziel ist eine Geschaeftsbedingung. Beim Privatverkauf
# entfaellt es -- dort wird der Betrag ueblicherweise direkt beglichen.
ZAHLUNGSZIEL = "Zahlbar innerhalb von 14 Tagen ohne Abzug."
DANK = "Vielen Dank für Ihren Auftrag!"
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 leerer_projektstand():
"""Projektfelder im Ausgangszustand. Datum auf heute."""
stand = {feld: DEFAULT_VALUES.get(feld) for feld in PROJEKT_FELDER}
stand['projektName'] = ''
stand['kundenName'] = ''
stand['kundenAdresse'] = ''
stand['rechnungsnummer'] = ''
stand['leistungsdatum'] = ''
stand['datum'] = datetime.now().strftime('%Y-%m-%d')
stand['positionen'] = [dict(DEFAULT_POSITION)]
return stand
def _vergleichbar(stand):
"""Projektfelder in eine vergleichbare Form bringen.
'geaendert' und das Datum bleiben aussen vor -- ein neuer Tag macht
aus einem gespeicherten Projekt keinen ungespeicherten Stand.
"""
return {
'projektName': projekt_name(stand.get('projektName')),
'kundenName': str(stand.get('kundenName') or ''),
'kundenAdresse': str(stand.get('kundenAdresse') or ''),
'rechnungsnummer': str(stand.get('rechnungsnummer') or ''),
'leistungsdatum': str(stand.get('leistungsdatum') or ''),
'positionen': saubere_positionen(stand.get('positionen')),
}
def ungespeicherte_aenderungen(config, projekte):
"""True, wenn der Arbeitsstand nicht so in den Projekten liegt.
Ein voellig leerer Stand zaehlt nicht als ungespeichert -- da ist
nichts zu verlieren.
"""
aktuell = _vergleichbar(config)
if aktuell == _vergleichbar(leerer_projektstand()):
return False
gespeichert = projekte.get(aktuell['projektName'])
if not isinstance(gespeichert, dict):
return True
return aktuell != _vergleichbar(gespeichert)
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,
'zahlungsziel': ZAHLUNGSZIEL,
'dank': DANK}
@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', ''),
'ungespeichert': ungespeicherte_aenderungen(config, projekte),
})
@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
config = _migriere(config)
save_config(config)
# Den geladenen Stand mitschicken, damit das Frontend die Felder
# direkt setzen kann -- ein location.reload() wuerde von Hand
# geaenderte Felder in manchen Browsern wiederherstellen.
return jsonify({'status': 'ok', 'name': name,
'stand': projekt_aus_config(config)})
@app.route('/projekte/neu', methods=['POST'])
def projekt_neu():
"""Setzt den Arbeitsstand auf einen leeren Auftrag zurueck.
Betriebswerte (Drucker, Strom, Stundenlohn, Marge, Steuer,
Firmendaten) bleiben unangetastet -- zurueckgesetzt wird nur, was
zu einem einzelnen Auftrag gehoert.
"""
with _config_lock:
config = load_config()
config.update(leerer_projektstand())
config = _migriere(config)
save_config(config)
return jsonify({'status': 'ok', 'stand': projekt_aus_config(config)})
@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)
schluss = f"{ZAHLUNGSZIEL} {DANK}" if calc.get('ist_unternehmer') else DANK
pdf.cell(180, 4, pdf_text(schluss))
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)