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>
This commit is contained in:
Erik Thiele
2026-08-22 11:38:26 +02:00
parent b6e5a59daa
commit c42fecacb9
7 changed files with 762 additions and 182 deletions

448
app.py
View File

@@ -111,25 +111,51 @@ def pdf_text(value):
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': '',
'materialGewicht': 0,
'materialPreisProKg': 25,
'druckzeit': 0,
'positionen': [dict(DEFAULT_POSITION)],
'stromverbrauch': 0.15,
'strompreis': 0.35,
'druckerAnschaffung': 500,
'druckerNutzungsdauer': 3,
'druckerBetriebsstunden': 2000,
'arbeitszeit': 0,
'stundenlohn': 25,
'gewinnmarge': 20,
'stueckzahl': 1,
# 'regel' = regelbesteuert (MwSt. wird ausgewiesen),
# 'kleinunternehmer' = §19 UStG (keine MwSt., dafuer Pflichthinweis).
'steuerregime': 'regel',
@@ -209,9 +235,52 @@ def _migriere(data):
# '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.
@@ -234,33 +303,59 @@ def save_config(data):
raise
def calculate(data):
"""Berechnet alle Kosten aus den Formularwerten.
_projekte_lock = threading.Lock()
Rueckgabe: Dict mit Einzelposten, Gesamtsummen und Detailwerten.
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.
"""
# Umrechnungen
druckzeit_stunden = float(data.get('druckzeit', 0)) / 60
arbeitszeit_stunden = float(data.get('arbeitszeit', 0)) / 60
material_kg = float(data.get('materialGewicht', 0)) / 1000
return str(roh or '').strip()[:200]
# Material-, Strom-, Abschreibungs- und Arbeitskosten
materialkosten = material_kg * float(data.get('materialPreisProKg', 0))
stromkosten = druckzeit_stunden * float(data.get('stromverbrauch', 0)) * float(data.get('strompreis', 0))
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
abschreibung = abschreibung_pro_stunde * druckzeit_stunden
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}
arbeitskosten = arbeitszeit_stunden * float(data.get('stundenlohn', 0))
# Gewinn, MwSt. und Bruttopreis
gesamtkosten_netto = materialkosten + stromkosten + abschreibung + arbeitskosten
gewinn = gesamtkosten_netto * (float(data.get('gewinnmarge', 0)) / 100)
verkaufspreis = gesamtkosten_netto + gewinn
# Das Steuerregime entscheidet, nicht mehr ein loser Bool-Schalter.
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'
@@ -269,50 +364,121 @@ def calculate(data):
mwst_satz = float(data.get('mwst_satz', 19))
except (TypeError, ValueError):
mwst_satz = 19.0
if mwst_satz < 0:
mwst_satz = 0.0
mwst_faktor = mwst_satz / 100 if mwst_ausweisen else 0
mwst = verkaufspreis * mwst_faktor
gesamt_brutto = verkaufspreis + mwst
mwst_satz = max(0.0, mwst_satz)
return regime, mwst_ausweisen, mwst_satz, (mwst_satz / 100 if mwst_ausweisen else 0)
material_preis_pro_kg = float(data.get('materialPreisProKg', 0))
# Gesamtwerte fuer den kompletten Auftrag. Basis ist der auf Cent
# gerundete Stueckpreis, damit die Rechnungszeile nachrechenbar
# bleibt (Einzelpreis x Menge == Zeilensumme).
stueckzahl = as_stueckzahl(data.get('stueckzahl', 1))
stueckpreis_netto = round(verkaufspreis, 2)
gesamt_netto = round(stueckpreis_netto * stueckzahl, 2)
gesamt_mwst = round(gesamt_netto * mwst_faktor, 2)
gesamt_endpreis = round(gesamt_netto + gesamt_mwst, 2)
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,
'gewinnmarge': float(data.get('gewinnmarge', 0)),
'steuerregime': regime,
'mwst_satz': mwst_satz,
'kleinunternehmer': regime == 'kleinunternehmer',
'privat': regime == 'privat',
# Steuernummer/USt-IdNr. gehoeren nur auf einen Unternehmer-Beleg.
'ist_unternehmer': regime != 'privat',
'gesamt_netto': gesamt_netto,
'gesamt_mwst': gesamt_mwst,
'gesamt_endpreis': gesamt_endpreis,
'materialkosten': round(materialkosten, 2),
'stromkosten': round(stromkosten, 2),
'abschreibung': round(abschreibung, 2),
'arbeitskosten': round(arbeitskosten, 2),
'gesamtkosten_netto': round(gesamtkosten_netto, 2),
'kosten': round(kosten, 2),
'gewinn': round(gewinn, 2),
'verkaufspreis': round(verkaufspreis, 2),
'mwst': round(mwst, 2),
'mwst_ausweisen': mwst_ausweisen,
'gesamt_brutto': round(gesamt_brutto, 2),
'stueckpreis': stueckpreis,
'zeilensumme': zeilensumme,
'druckzeit_stunden': round(druckzeit_stunden, 2),
'arbeitszeit_stunden': round(arbeitszeit_stunden, 2),
'material_kg': round(material_kg, 3),
'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4),
'kosten_pro_gramm': round(material_preis_pro_kg / 1000, 3),
'strom_pro_stunde': round(float(data.get('stromverbrauch', 0)) * float(data.get('strompreis', 0)), 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),
}
@@ -373,6 +539,10 @@ def save_config_route():
# 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)
@@ -383,6 +553,95 @@ def save_config_route():
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."""
@@ -454,7 +713,6 @@ def invoice_pdf():
data = request.form
config = load_config()
calc = calculate(config)
stueckzahl = calc['stueckzahl']
gesamt_netto = calc['gesamt_netto']
gesamt_mwst = calc['gesamt_mwst']
gesamt_brutto = calc['gesamt_endpreis']
@@ -593,12 +851,18 @@ def invoice_pdf():
pdf.set_draw_color(0, 0, 0)
pdf.set_font("Helvetica", "", 10)
pdf.set_x(left)
pos_label = f"3D-Druck: {projekt}" if projekt else "3D-Druck"
pdf.cell(85, 7, pos_label)
pdf.cell(20, 7, str(stueckzahl), align='R')
pdf.cell(30, 7, f"{calc['verkaufspreis']:.2f} EUR", align='R')
pdf.cell(30, 7, f"{gesamt_netto:.2f} EUR", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
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)
@@ -642,35 +906,51 @@ def invoice_pdf():
@app.route('/export', methods=['POST'])
def export():
"""Exportiert die Kalkulation als TXT-Datei."""
data = request.get_json()
data = request.get_json(silent=True) or {}
config = load_config()
merged = {**config, **data}
calc = calculate(merged)
lines = [
"3D-DRUCK KOSTENKALKULATION",
"=" * 50 + "\n",
f"Projekt: {data.get('projektName', '')}",
f"Datum: {data.get('datum', '')}\n",
"KOSTENAUFSTELLUNG:",
f"Materialkosten: {calc['materialkosten']:.2f} EUR",
f"Stromkosten: {calc['stromkosten']:.2f} EUR",
f"Abschreibung: {calc['abschreibung']:.2f} EUR",
f"Arbeitskosten: {calc['arbeitskosten']:.2f} EUR",
f"Gesamtkosten: {calc['gesamtkosten_netto']:.2f} EUR",
f"Gewinn: {calc['gewinn']:.2f} EUR",
f"Verkaufspreis (netto): {calc['verkaufspreis']:.2f} EUR",
"=" * 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(0, 16 - len(satz))}}{calc['mwst']:.2f} EUR")
lines.append(f"Preis pro Stuck: {calc['gesamt_brutto']:.2f} EUR")
lines.append("")
lines.append(f"Stuckzahl: {calc['stueckzahl']}")
lines.append(f"Auftrag netto: {calc['gesamt_netto']:.2f} EUR")
if calc.get('mwst_ausweisen'):
lines.append(f"MwSt. ({satz}%) gesamt: {calc['gesamt_mwst']:.2f} EUR")
lines.append(f"GESAMTPREIS: {calc['gesamt_endpreis']:.2f} EUR")
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)