Ein Zahlungsziel ist eine Geschaeftsbedingung. Auf einem Privatbeleg hat es nichts zu suchen -- dort wird der Betrag ueblicherweise direkt beglichen. ZAHLUNGSZIEL und DANK sind jetzt getrennte Konstanten. Das Zahlungsziel erscheint nur bei 'kleinunternehmer' und 'regel', der Dank in allen drei Regimen. Gilt fuer PDF und Rechnungsvorschau. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
996 lines
38 KiB
Python
996 lines
38 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 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', '')})
|
|
|
|
|
|
@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)
|
|
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)
|