diff --git a/.gitignore b/.gitignore index e92971e..b7ce114 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .DS_Store druckkalkulation_config.yaml static/logo.* +druckkalkulation_projekte.yaml diff --git a/AGENTS.md b/AGENTS.md index b344f6f..21dad6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,27 @@ Kein Test, Lint, Typecheck oder CI vorhanden. README.md und AGENTS.md enthalten die Ableitung aus Alt-Configs nicht) und in `/save_config` nach dem Update (sonst steht Widerspruechliches auf der Platte). - Der MwSt.-Satz kommt aus `mwst_satz`, nicht mehr hartcodiert. +- **Positionen**: `config['positionen']` ist eine Liste von Dicts mit den + Feldern aus `DEFAULT_POSITION`. `calculate()` liefert `calc['positionen']` + (je Position `stueckpreis` und `zeilensumme`) plus die Auftragssummen. + Jede Liste aus Config oder Client geht durch `saubere_positionen()` -- + nie ungeprueft uebernehmen. Es gibt immer mindestens eine Position. +- Die Auftragssumme ist die Summe der bereits gerundeten Zeilen, damit die + Rechnung zeilenweise nachrechenbar bleibt. +- `sammlePositionen()` im Frontend liefert `null` auf Seiten ohne + Positionsliste (Einstellungen, Rechnung). Dann darf `positionen` NICHT + mitgesendet werden -- sonst wuerde ein Tastendruck dort die Positionen + loeschen. +- **Projekte**: `druckkalkulation_projekte.yaml` haelt benannte Staende, + Schluessel ist der Projektname. Nur `PROJEKT_FELDER` werden gesichert -- + Betriebswerte (Marge, Steuer, Drucker, Firmendaten) gehoeren bewusst NICHT + dazu, sonst wuerde das Laden eines alten Projekts sie ueberschreiben. + Eigenes Lock `_projekte_lock`, gleiche atomare Schreibtechnik wie die Config. + Die Preise in der Projektliste werden mit den AKTUELLEN Betriebswerten + gerechnet, nicht mit denen von damals. +- `druckkalkulation_config.yaml` ist der Arbeitsstand, nicht das Archiv. +- Alte Configs mit den Flachfeldern (`materialGewicht` etc.) migriert + `_migriere()` in eine einzelne Position und entfernt die Altfelder. - **Saeamtliche UI-Texte sind Deutsch** — Labels, Fehlermeldungen, Rechnungsinhalte. - Nur `.venv/`, `druckkalkulation_config.yaml` und `static/logo.*` werden ignoriert. - `3dDruckKalkulator.py` ist die alte Tkinter-Version und wird nicht mehr verwendet (nicht im Tracking). diff --git a/README.md b/README.md index ecf6450..ed5c9b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ sich ueber `HOST` und `PORT` ueberschreiben. ## Funktionen +- **Mehrere Positionen pro Auftrag** — ein Objekt aus mehreren Teilen wird als + Positionsliste kalkuliert und als eigene Rechnungszeilen ausgewiesen. + Material, Preis/kg, Druckzeit, Arbeitszeit und Stückzahl gelten je Position; + Drucker, Strom, Stundenlohn, Marge und Steuer gelten für den ganzen Auftrag. +- **Projekte speichern und laden** — benannte Kalkulationen bleiben erhalten + und lassen sich jederzeit wieder aufrufen (`druckkalkulation_projekte.yaml`). + Betriebswerte (Drucker, Strom, Stundenlohn, Marge, Steuer) gehören *nicht* + zum Projekt und bleiben beim Laden unangetastet. - **Live-Berechnung** bei jeder Eingabe (Material, Strom, Abschreibung, Arbeit, Gewinn) - **Dark Mode** (persistiert in YAML-Konfiguration) - **PDF-Rechnung** mit Logo, Kundendaten und Positionstabelle (via fpdf2) @@ -112,7 +120,8 @@ static/style.css # Minimales Custom-CSS static/script.js # Live-Berechnung + Auto-Save proxmox-setup.sh # Ersteinrichtung LXC-Container proxmox-update.sh # Update LXC-Container -druckkalkulation_config.yaml # Automatisch erstellte Konfiguration (ignoriert) +druckkalkulation_config.yaml # Aktueller Arbeitsstand (ignoriert) +druckkalkulation_projekte.yaml # Gespeicherte Projekte (ignoriert) ``` ## Routen @@ -120,12 +129,16 @@ druckkalkulation_config.yaml # Automatisch erstellte Konfiguration (ignoriert) | Methode | Pfad | Beschreibung | |---------|------|-------------| | GET | `/` | Hauptseite mit Formular | -| POST | `/calculate` | JSON-Berechnung (AJAX) | +| POST | `/calculate` | JSON-Berechnung aller Positionen (AJAX) | | POST | `/save_config` | Speichert Werte in YAML | | GET | `/invoice` | Rechnungsseite | | POST | `/invoice/pdf` | PDF-Download | | POST | `/export` | TXT-Download | | GET | `/settings` | Einstellungsseite | +| GET | `/projekte` | Liste der gespeicherten Projekte (JSON) | +| POST | `/projekte/speichern` | Aktuellen Stand als Projekt sichern | +| POST | `/projekte/laden` | Projekt in den Arbeitsstand laden | +| POST | `/projekte/loeschen` | Projekt entfernen | | POST | `/upload_logo` | Logo hochladen | | POST | `/delete_logo` | Logo loschen | diff --git a/app.py b/app.py index 6090bdf..c90b018 100644 --- a/app.py +++ b/app.py @@ -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: ". 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) diff --git a/static/script.js b/static/script.js index e5b3e51..a48032c 100644 --- a/static/script.js +++ b/static/script.js @@ -10,21 +10,91 @@ let saveTimeout = null; let calcTimeout = null; +/** Liest ein einzelnes Feld gemaess seines data-type. */ +function feldWert(el) { + const type = el.dataset.type || 'text'; + if (type === 'number') return parseFloat(el.value) || 0; + if (type === 'checkbox') return el.checked; + return el.value; +} + /** Sammelt alle Formularwerte anhand ihrer data-var Attribute. */ function getFormData() { const data = {}; document.querySelectorAll('[data-var]').forEach(el => { - const key = el.dataset.var; - const type = el.dataset.type || 'text'; - let val = el.value; - if (type === 'number') val = parseFloat(val) || 0; - else if (type === 'checkbox') val = el.checked; - data[key] = val; + data[el.dataset.var] = feldWert(el); }); + const positionen = sammlePositionen(); + if (positionen) data.positionen = positionen; data.dark_mode = document.documentElement.getAttribute('data-bs-theme') === 'dark'; return data; } +/** Liest die Positionskarten aus. null, wenn die Seite keine hat + * (Einstellungen, Rechnung) -- dann bleibt die Config unangetastet. */ +function sammlePositionen() { + const liste = document.getElementById('positionen-liste'); + if (!liste) return null; + return [...liste.querySelectorAll('.position-karte')].map(karte => { + const pos = {}; + karte.querySelectorAll('[data-pos-var]').forEach(el => { + pos[el.dataset.posVar] = feldWert(el); + }); + return pos; + }); +} + +/** Haengt eine Positionskarte an die Liste an. */ +function positionAnlegen(werte = {}) { + const liste = document.getElementById('positionen-liste'); + const vorlage = document.getElementById('position-vorlage'); + if (!liste || !vorlage) return; + + const karte = vorlage.content.firstElementChild.cloneNode(true); + karte.querySelectorAll('[data-pos-var]').forEach(el => { + const key = el.dataset.posVar; + el.value = werte[key] !== undefined ? werte[key] : (key === 'stueckzahl' ? 1 : ''); + }); + karte.querySelector('.position-entfernen') + .addEventListener('click', () => positionEntfernen(karte)); + liste.appendChild(karte); + nummeriereNeu(); +} + +/** Neue, leere Position -- speichert und rechnet sofort neu. */ +function positionHinzufuegen() { + positionAnlegen({ materialPreisProKg: standardMaterialpreis(), stueckzahl: 1 }); + onInputChange(); +} + +/** Uebernimmt den Materialpreis der letzten Position als Vorgabe. */ +function standardMaterialpreis() { + const felder = document.querySelectorAll('[data-pos-var="materialPreisProKg"]'); + if (!felder.length) return 25; + return parseFloat(felder[felder.length - 1].value) || 25; +} + +/** Entfernt eine Position. Die letzte bleibt stehen -- ohne Position + * gaebe es nichts zu rechnen und keine Eingabefelder mehr. */ +function positionEntfernen(karte) { + const liste = document.getElementById('positionen-liste'); + if (liste.querySelectorAll('.position-karte').length <= 1) { + showNotification('Mindestens eine Position wird benoetigt'); + return; + } + karte.remove(); + nummeriereNeu(); + onInputChange(); +} + +/** Haelt die sichtbare Nummerierung nach Hinzufuegen/Entfernen aktuell. */ +function nummeriereNeu() { + document.querySelectorAll('#positionen-liste .position-karte') + .forEach((karte, i) => { + karte.querySelector('.position-nummer').textContent = i + 1; + }); +} + /** Sendet Formulardaten an /calculate und aktualisiert die Ergebnisanzeige. * Auf Seiten ohne Ergebnisblock (Einstellungen, Rechnung) entfaellt beides. */ async function recalculate() { @@ -48,64 +118,69 @@ function renderResult(calc) { const ziel = document.getElementById('result-text'); if (!ziel) return; const eur = (v) => `${String(v.toFixed(2)).padStart(10)} EUR`; - const marge = `Gewinn (${calc.gewinnmarge}%):`.padEnd(23); + const zeile = (label, wert) => `${label.padEnd(23)}${eur(wert)}`; + const lines = []; - const lines = [ - `PRO STUCK`, - `Materialkosten: ${eur(calc.materialkosten)}`, - `Stromkosten: ${eur(calc.stromkosten)}`, - `Abschreibung Drucker: ${eur(calc.abschreibung)}`, - `Arbeitskosten: ${eur(calc.arbeitskosten)}`, - `${'\u2500'.repeat(40)}`, - `Gesamtkosten: ${eur(calc.gesamtkosten_netto)}`, - '', - `${marge}${eur(calc.gewinn)}`, - `${'\u2500'.repeat(40)}`, - `Verkaufspreis (netto): ${eur(calc.verkaufspreis)}`, - ]; - if (calc.mwst_ausweisen) { - lines.push(`MwSt. (19%): ${eur(calc.mwst)}`); - } - lines.push(`Preis pro Stuck: ${eur(calc.gesamt_brutto)}`); + calc.positionen.forEach((pos, i) => { + const titel = pos.name ? pos.name : `Position ${i + 1}`; + lines.push(`${(i + 1)}. ${titel.toUpperCase()}`); + lines.push(zeile(' Material:', pos.materialkosten)); + lines.push(zeile(' Strom:', pos.stromkosten)); + lines.push(zeile(' Abschreibung:', pos.abschreibung)); + lines.push(zeile(' Arbeit:', pos.arbeitskosten)); + lines.push(zeile(' Kosten je Stuck:', pos.kosten)); + lines.push(zeile(` Gewinn (${calc.gewinnmarge}%):`, pos.gewinn)); + lines.push(zeile(' Stueckpreis:', pos.stueckpreis)); + lines.push(` ${String(pos.stueckzahl).padStart(3)} x ${pos.stueckpreis.toFixed(2)}` + + ` = ${String(pos.zeilensumme.toFixed(2)).padStart(10)} EUR`); + lines.push(''); + }); - // Auftragssumme: bei Stueckzahl 1 identisch, aber explizit ausgewiesen, - // damit der Endbetrag nicht mit dem Stueckpreis verwechselt wird. - lines.push(''); - lines.push(`AUFTRAG (${calc.stueckzahl} Stuck)`); - lines.push(`Auftrag netto: ${eur(calc.gesamt_netto)}`); + lines.push('\u2500'.repeat(40)); + lines.push(`AUFTRAG (${calc.anzahl_positionen} Pos., ${calc.teile_gesamt} Teile)`); + lines.push(zeile('Materialkosten:', calc.summe_materialkosten)); + lines.push(zeile('Stromkosten:', calc.summe_stromkosten)); + lines.push(zeile('Abschreibung:', calc.summe_abschreibung)); + lines.push(zeile('Arbeitskosten:', calc.summe_arbeitskosten)); + lines.push(zeile('Kosten gesamt:', calc.summe_kosten)); + lines.push(zeile('Gewinn gesamt:', calc.summe_gewinn)); + lines.push('\u2500'.repeat(40)); + lines.push(zeile('Auftrag netto:', calc.gesamt_netto)); if (calc.mwst_ausweisen) { - lines.push(`MwSt. (19%): ${eur(calc.gesamt_mwst)}`); + lines.push(zeile(`MwSt. (${calc.mwst_satz}%):`, calc.gesamt_mwst)); } - lines.push(`${'\u2550'.repeat(40)}`); - lines.push(`GESAMT: ${eur(calc.gesamt_endpreis)}`); + lines.push('\u2550'.repeat(40)); + lines.push(zeile('GESAMT:', calc.gesamt_endpreis)); ziel.textContent = lines.join('\n'); const details = [ - `Druckzeit: ${calc.druckzeit_stunden} Stunden`, - `Materialgewicht: ${calc.material_kg} kg`, + `Druckzeit gesamt: ${calc.druckzeit_stunden} Stunden`, + `Materialgewicht gesamt: ${calc.material_kg} kg`, `Abschreibung pro Stunde: ${calc.abschreibung_pro_stunde} EUR`, - `Kosten pro Gramm: ${calc.kosten_pro_gramm} EUR`, `Stromkosten pro Stunde: ${calc.strom_pro_stunde} EUR`, ]; const detailZiel = document.getElementById('detail-text'); if (detailZiel) detailZiel.textContent = details.join('\n'); } +/** Schreibt den aktuellen Formularstand sofort weg. */ +async function sofortSpeichern() { + clearTimeout(saveTimeout); + try { + await fetch('/save_config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(getFormData()), + }); + } catch (e) { + console.error('Save error', e); + } +} + /** Plant ein Auto-Save in die YAML-Konfiguration (debounced 500ms). */ function scheduleSave() { clearTimeout(saveTimeout); - saveTimeout = setTimeout(async () => { - const data = getFormData(); - try { - await fetch('/save_config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); - } catch (e) { - console.error('Save error', e); - } - }, 500); + saveTimeout = setTimeout(sofortSpeichern, 500); } /** Wird bei jeder Eingabe aufgerufen: speichert + berechnet neu. @@ -154,6 +229,156 @@ async function exportData() { } } +/* --------------------------------------------------------------------------- + * Projekte: benannte Staende speichern und wieder laden + * ------------------------------------------------------------------------- */ + +let bekannteProjekte = []; + +/** Holt die Projektliste und fuellt die Auswahl. */ +async function projekteAktualisieren() { + const auswahl = document.getElementById('projekt-auswahl'); + if (!auswahl) return; + try { + const res = await fetch('/projekte'); + const daten = await res.json(); + bekannteProjekte = daten.projekte || []; + } catch (e) { + console.error('Projektliste nicht ladbar', e); + return; + } + + const vorher = auswahl.value; + auswahl.innerHTML = ''; + const info = document.getElementById('projekt-info'); + + if (!bekannteProjekte.length) { + const opt = new Option('— noch keine Projekte gespeichert —', ''); + opt.disabled = true; + auswahl.add(opt); + if (info) info.textContent = 'Noch keine Projekte gespeichert.'; + return; + } + + bekannteProjekte.forEach(p => { + const teile = `${p.anzahl_positionen} Pos., ${p.teile_gesamt} Teile`; + const preis = p.gesamt_endpreis.toFixed(2) + ' EUR'; + auswahl.add(new Option(`${p.name} (${teile}, ${preis})`, p.name)); + }); + // Nach Moeglichkeit das aktuell geladene Projekt vorauswaehlen. + const aktuell = document.getElementById('projekt-name'); + const wunsch = [vorher, aktuell && aktuell.value].find( + v => v && bekannteProjekte.some(p => p.name === v)); + if (wunsch) auswahl.value = wunsch; + if (info) { + const gewaehlt = bekannteProjekte.find(p => p.name === auswahl.value); + info.textContent = gewaehlt + ? `Zuletzt geändert: ${gewaehlt.geaendert}` + + (gewaehlt.kundenName ? ` · Kunde: ${gewaehlt.kundenName}` : '') + : `${bekannteProjekte.length} Projekte gespeichert.`; + } +} + +/** Speichert den aktuellen Stand unter dem eingetragenen Projektnamen. */ +async function projektSpeichern() { + const feld = document.getElementById('projekt-name'); + const name = (feld.value || '').trim(); + if (!name) { + showNotification('Bitte zuerst einen Projektnamen vergeben'); + feld.focus(); + return; + } + if (bekannteProjekte.some(p => p.name === name) && + !confirm(`Projekt "${name}" existiert bereits.\n\nMit dem aktuellen Stand ueberschreiben?`)) { + return; + } + // Erst den offenen Autosave durchdruecken, damit der Server den + // wirklich aktuellen Stand wegschreibt. + await sofortSpeichern(); + try { + const res = await fetch('/projekte/speichern', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const daten = await res.json(); + if (!res.ok) { + showNotification(daten.error || 'Speichern fehlgeschlagen'); + return; + } + showNotification(daten.neu ? `Projekt "${name}" angelegt` : `Projekt "${name}" aktualisiert`); + await projekteAktualisieren(); + } catch (e) { + showNotification('Speichern fehlgeschlagen'); + } +} + +/** Laedt das ausgewaehlte Projekt in den Arbeitsstand. */ +async function projektLaden() { + const auswahl = document.getElementById('projekt-auswahl'); + const name = auswahl.value; + if (!name) { + showNotification('Kein Projekt ausgewaehlt'); + return; + } + const offen = (document.getElementById('projekt-name').value || '').trim(); + if (offen && offen !== name && + !confirm(`Der aktuelle Stand "${offen}" wird durch "${name}" ersetzt.\n\n` + + `Ungespeicherte Aenderungen gehen dabei verloren. Fortfahren?`)) { + return; + } + try { + const res = await fetch('/projekte/laden', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const daten = await res.json(); + if (!res.ok) { + showNotification(daten.error || 'Laden fehlgeschlagen'); + return; + } + // Neu laden: die Positionskarten baut der Server-Datenblock auf. + location.reload(); + } catch (e) { + showNotification('Laden fehlgeschlagen'); + } +} + +/** Loescht das ausgewaehlte Projekt (der Arbeitsstand bleibt bestehen). */ +async function projektLoeschen() { + const auswahl = document.getElementById('projekt-auswahl'); + const name = auswahl.value; + if (!name) { + showNotification('Kein Projekt ausgewaehlt'); + return; + } + if (!confirm(`Projekt "${name}" endgueltig loeschen?\n\n` + + `Der aktuell geoeffnete Stand bleibt erhalten.`)) return; + try { + const res = await fetch('/projekte/loeschen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const daten = await res.json(); + if (!res.ok) { + showNotification(daten.error || 'Loeschen fehlgeschlagen'); + return; + } + showNotification(`Projekt "${name}" geloescht`); + await projekteAktualisieren(); + } catch (e) { + showNotification('Loeschen fehlgeschlagen'); + } +} + +/** Haelt die Info-Zeile zur Auswahl aktuell. */ +document.addEventListener('DOMContentLoaded', () => { + const auswahl = document.getElementById('projekt-auswahl'); + if (auswahl) auswahl.addEventListener('change', projekteAktualisieren); +}); + /** Zeigt eine kurze Toast-Notification oben rechts an. */ function showNotification(msg) { const container = document.getElementById('notification'); @@ -197,7 +422,19 @@ async function deleteLogo() { } } -/** Initiale Berechnung beim Seitenladen. */ +/** Positionen aus dem Server-JSON aufbauen, dann initial rechnen. */ document.addEventListener('DOMContentLoaded', () => { + const daten = document.getElementById('positionen-daten'); + if (daten) { + let positionen = []; + try { + positionen = JSON.parse(daten.textContent) || []; + } catch (e) { + console.error('Positionen nicht lesbar', e); + } + if (!positionen.length) positionen = [{ materialPreisProKg: 25, stueckzahl: 1 }]; + positionen.forEach(positionAnlegen); + } recalculate(); + projekteAktualisieren(); }); diff --git a/templates/index.html b/templates/index.html index 39df1a7..fede732 100644 --- a/templates/index.html +++ b/templates/index.html @@ -44,46 +44,38 @@
-
Projektinformationen
+
Projekt
-
- - + +
+ +
-
- - +
Übergreifender Name des Auftrags. Die einzelnen Teile werden unten als Positionen erfasst.
+ +
+ + +
+ + +
+
Noch keine Projekte gespeichert.
-
-
Material
-
-
- - -
-
- - -
-
-
+
-
-
Druckparameter
-
-
- - -
-
- - -
-
-
+
@@ -123,6 +115,47 @@
+ + + diff --git a/templates/invoice.html b/templates/invoice.html index 5c163d5..03b19e1 100644 --- a/templates/invoice.html +++ b/templates/invoice.html @@ -74,22 +74,17 @@
Vorschau
- {% set stueckzahl = calc.stueckzahl %} - {% set gesamt_netto = calc.gesamt_netto %} - {% set gesamt_mwst = calc.gesamt_mwst %} - {% set gesamt_brutto = calc.gesamt_endpreis %}
LEISTUNGSBESCHREIBUNG: -{{ '\u2500' * 50 }} -Position: 3D-Druck - {{ config.projektName }} -Menge: {{ stueckzahl }} -Einzelpreis: {{ "%.2f"|format(calc.verkaufspreis) }} EUR -Gesamt: {{ "%.2f"|format(gesamt_netto) }} EUR - -{{ '\u2500' * 50 }} -Nettobetrag: {{ "%.2f"|format(gesamt_netto) }} EUR -{% if calc.mwst_ausweisen %}MwSt. ({{ '%g'|format(calc.mwst_satz) }}%): {{ "%.2f"|format(gesamt_mwst) }} EUR -{% endif %}{{ '\u2550' * 50 }} -GESAMTBETRAG{% if calc.mwst_ausweisen %} (brutto){% endif %}: {{ "%.2f"|format(gesamt_brutto) }} EUR +{{ '\u2500' * 58 }} +{% for pos in calc.positionen -%} +{{ loop.index }}. {{ pos.name if pos.name else (config.projektName if calc.anzahl_positionen == 1 else 'Position ' ~ loop.index) }} + {{ pos.stueckzahl }} x {{ "%.2f"|format(pos.stueckpreis) }} EUR = {{ "%.2f"|format(pos.zeilensumme) }} EUR +{% endfor %} +{{ '\u2500' * 58 }} +Nettobetrag: {{ "%.2f"|format(calc.gesamt_netto) }} EUR +{% if calc.mwst_ausweisen %}MwSt. ({{ '%g'|format(calc.mwst_satz) }}%): {{ "%.2f"|format(calc.gesamt_mwst) }} EUR +{% endif %}{{ '\u2550' * 58 }} +GESAMTBETRAG{% if calc.mwst_ausweisen %} (brutto){% endif %}: {{ "%.2f"|format(calc.gesamt_endpreis) }} EUR {% if calc.kleinunternehmer %} {{ kleinunternehmer_hinweis }} {% elif calc.privat %}