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

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ __pycache__/
.DS_Store .DS_Store
druckkalkulation_config.yaml druckkalkulation_config.yaml
static/logo.* static/logo.*
druckkalkulation_projekte.yaml

View File

@@ -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 die Ableitung aus Alt-Configs nicht) und in `/save_config` nach dem
Update (sonst steht Widerspruechliches auf der Platte). Update (sonst steht Widerspruechliches auf der Platte).
- Der MwSt.-Satz kommt aus `mwst_satz`, nicht mehr hartcodiert. - 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. - **Saeamtliche UI-Texte sind Deutsch** — Labels, Fehlermeldungen, Rechnungsinhalte.
- Nur `.venv/`, `druckkalkulation_config.yaml` und `static/logo.*` werden ignoriert. - 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). - `3dDruckKalkulator.py` ist die alte Tkinter-Version und wird nicht mehr verwendet (nicht im Tracking).

View File

@@ -29,6 +29,14 @@ sich ueber `HOST` und `PORT` ueberschreiben.
## Funktionen ## 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) - **Live-Berechnung** bei jeder Eingabe (Material, Strom, Abschreibung, Arbeit, Gewinn)
- **Dark Mode** (persistiert in YAML-Konfiguration) - **Dark Mode** (persistiert in YAML-Konfiguration)
- **PDF-Rechnung** mit Logo, Kundendaten und Positionstabelle (via fpdf2) - **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 static/script.js # Live-Berechnung + Auto-Save
proxmox-setup.sh # Ersteinrichtung LXC-Container proxmox-setup.sh # Ersteinrichtung LXC-Container
proxmox-update.sh # Update 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 ## Routen
@@ -120,12 +129,16 @@ druckkalkulation_config.yaml # Automatisch erstellte Konfiguration (ignoriert)
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
|---------|------|-------------| |---------|------|-------------|
| GET | `/` | Hauptseite mit Formular | | GET | `/` | Hauptseite mit Formular |
| POST | `/calculate` | JSON-Berechnung (AJAX) | | POST | `/calculate` | JSON-Berechnung aller Positionen (AJAX) |
| POST | `/save_config` | Speichert Werte in YAML | | POST | `/save_config` | Speichert Werte in YAML |
| GET | `/invoice` | Rechnungsseite | | GET | `/invoice` | Rechnungsseite |
| POST | `/invoice/pdf` | PDF-Download | | POST | `/invoice/pdf` | PDF-Download |
| POST | `/export` | TXT-Download | | POST | `/export` | TXT-Download |
| GET | `/settings` | Einstellungsseite | | 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 | `/upload_logo` | Logo hochladen |
| POST | `/delete_logo` | Logo loschen | | POST | `/delete_logo` | Logo loschen |

446
app.py
View File

@@ -111,25 +111,51 @@ def pdf_text(value):
app = Flask(__name__) app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024
CONFIG_FILE = 'druckkalkulation_config.yaml' 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') 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 = { DEFAULT_VALUES = {
'projektName': '', 'projektName': '',
'kundenName': '', 'kundenName': '',
'kundenAdresse': '', 'kundenAdresse': '',
'materialGewicht': 0, 'positionen': [dict(DEFAULT_POSITION)],
'materialPreisProKg': 25,
'druckzeit': 0,
'stromverbrauch': 0.15, 'stromverbrauch': 0.15,
'strompreis': 0.35, 'strompreis': 0.35,
'druckerAnschaffung': 500, 'druckerAnschaffung': 500,
'druckerNutzungsdauer': 3, 'druckerNutzungsdauer': 3,
'druckerBetriebsstunden': 2000, 'druckerBetriebsstunden': 2000,
'arbeitszeit': 0,
'stundenlohn': 25, 'stundenlohn': 25,
'gewinnmarge': 20, 'gewinnmarge': 20,
'stueckzahl': 1,
# 'regel' = regelbesteuert (MwSt. wird ausgewiesen), # 'regel' = regelbesteuert (MwSt. wird ausgewiesen),
# 'kleinunternehmer' = §19 UStG (keine MwSt., dafuer Pflichthinweis). # 'kleinunternehmer' = §19 UStG (keine MwSt., dafuer Pflichthinweis).
'steuerregime': 'regel', 'steuerregime': 'regel',
@@ -209,9 +235,52 @@ def _migriere(data):
# 'mwst_ausweisen' bleibt als abgeleitetes Feld erhalten, damit # 'mwst_ausweisen' bleibt als abgeleitetes Feld erhalten, damit
# Templates und Export nicht an zwei Stellen entscheiden muessen. # Templates und Export nicht an zwei Stellen entscheiden muessen.
data['mwst_ausweisen'] = data['steuerregime'] == 'regel' 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 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): def save_config(data):
"""Schreibt das uebergebene Dict atomar als YAML-Datei. """Schreibt das uebergebene Dict atomar als YAML-Datei.
@@ -234,33 +303,59 @@ def save_config(data):
raise raise
def calculate(data): _projekte_lock = threading.Lock()
"""Berechnet alle Kosten aus den Formularwerten.
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 return str(roh or '').strip()[:200]
druckzeit_stunden = float(data.get('druckzeit', 0)) / 60
arbeitszeit_stunden = float(data.get('arbeitszeit', 0)) / 60
material_kg = float(data.get('materialGewicht', 0)) / 1000
# 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)) def projekt_aus_config(config):
nutzung = float(data.get('druckerNutzungsdauer', 1)) """Schneidet die Projektfelder aus dem aktuellen Arbeitsstand heraus."""
betrieb = float(data.get('druckerBetriebsstunden', 1)) return {feld: config.get(feld, DEFAULT_VALUES.get(feld))
abschreibung_pro_stunde = anschaffung / (nutzung * betrieb) if (nutzung * betrieb) > 0 else 0 for feld in PROJEKT_FELDER}
abschreibung = abschreibung_pro_stunde * druckzeit_stunden
arbeitskosten = arbeitszeit_stunden * float(data.get('stundenlohn', 0))
# Gewinn, MwSt. und Bruttopreis def steuer_kontext(data):
gesamtkosten_netto = materialkosten + stromkosten + abschreibung + arbeitskosten """Liest Regime und MwSt.-Satz robust aus den Formularwerten."""
gewinn = gesamtkosten_netto * (float(data.get('gewinnmarge', 0)) / 100)
verkaufspreis = gesamtkosten_netto + gewinn
# Das Steuerregime entscheidet, nicht mehr ein loser Bool-Schalter.
regime = str(data.get('steuerregime', 'regel')).lower() regime = str(data.get('steuerregime', 'regel')).lower()
if regime not in STEUERREGIME: if regime not in STEUERREGIME:
regime = 'regel' regime = 'regel'
@@ -269,50 +364,121 @@ def calculate(data):
mwst_satz = float(data.get('mwst_satz', 19)) mwst_satz = float(data.get('mwst_satz', 19))
except (TypeError, ValueError): except (TypeError, ValueError):
mwst_satz = 19.0 mwst_satz = 19.0
if mwst_satz < 0: mwst_satz = max(0.0, mwst_satz)
mwst_satz = 0.0 return regime, mwst_ausweisen, mwst_satz, (mwst_satz / 100 if mwst_ausweisen else 0)
mwst_faktor = mwst_satz / 100 if mwst_ausweisen else 0
mwst = verkaufspreis * mwst_faktor
gesamt_brutto = verkaufspreis + mwst
material_preis_pro_kg = float(data.get('materialPreisProKg', 0))
# Gesamtwerte fuer den kompletten Auftrag. Basis ist der auf Cent def positions_label(pos, nummer, projekt, anzahl):
# gerundete Stueckpreis, damit die Rechnungszeile nachrechenbar """Beschriftung einer Rechnungszeile.
# bleibt (Einzelpreis x Menge == Zeilensumme).
stueckzahl = as_stueckzahl(data.get('stueckzahl', 1)) Bei einer einzelnen namenlosen Position bleibt es beim bisherigen
stueckpreis_netto = round(verkaufspreis, 2) "3D-Druck: <Projekt>". Sobald mehrere Positionen im Spiel sind,
gesamt_netto = round(stueckpreis_netto * stueckzahl, 2) braucht jede Zeile eine eigene, unterscheidbare Bezeichnung.
gesamt_mwst = round(gesamt_netto * mwst_faktor, 2) """
gesamt_endpreis = round(gesamt_netto + gesamt_mwst, 2) 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 { return {
'name': str(pos.get('name', '') or ''),
'stueckzahl': stueckzahl, '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), 'materialkosten': round(materialkosten, 2),
'stromkosten': round(stromkosten, 2), 'stromkosten': round(stromkosten, 2),
'abschreibung': round(abschreibung, 2), 'abschreibung': round(abschreibung, 2),
'arbeitskosten': round(arbeitskosten, 2), 'arbeitskosten': round(arbeitskosten, 2),
'gesamtkosten_netto': round(gesamtkosten_netto, 2), 'kosten': round(kosten, 2),
'gewinn': round(gewinn, 2), 'gewinn': round(gewinn, 2),
'verkaufspreis': round(verkaufspreis, 2), 'stueckpreis': stueckpreis,
'mwst': round(mwst, 2), 'zeilensumme': zeilensumme,
'mwst_ausweisen': mwst_ausweisen,
'gesamt_brutto': round(gesamt_brutto, 2),
'druckzeit_stunden': round(druckzeit_stunden, 2), 'druckzeit_stunden': round(druckzeit_stunden, 2),
'arbeitszeit_stunden': round(arbeitszeit_stunden, 2),
'material_kg': round(material_kg, 3), 'material_kg': round(material_kg, 3),
'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4),
'kosten_pro_gramm': round(material_preis_pro_kg / 1000, 3), '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 # Nur bekannte Schluessel uebernehmen -- sonst kann ein beliebiger
# Client die Config mit fremden Feldern vollschreiben. # Client die Config mit fremden Feldern vollschreiben.
gefiltert = {k: v for k, v in data.items() if k in DEFAULT_VALUES} 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: with _config_lock:
existing = load_config() existing = load_config()
existing.update(gefiltert) existing.update(gefiltert)
@@ -383,6 +553,95 @@ def save_config_route():
return jsonify({'status': 'ok', 'gespeichert': len(gefiltert)}) 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']) @app.route('/upload_logo', methods=['POST'])
def upload_logo(): def upload_logo():
"""Logo-Datei hochladen, auf gueltiges Bild pruefen und speichern.""" """Logo-Datei hochladen, auf gueltiges Bild pruefen und speichern."""
@@ -454,7 +713,6 @@ def invoice_pdf():
data = request.form data = request.form
config = load_config() config = load_config()
calc = calculate(config) calc = calculate(config)
stueckzahl = calc['stueckzahl']
gesamt_netto = calc['gesamt_netto'] gesamt_netto = calc['gesamt_netto']
gesamt_mwst = calc['gesamt_mwst'] gesamt_mwst = calc['gesamt_mwst']
gesamt_brutto = calc['gesamt_endpreis'] gesamt_brutto = calc['gesamt_endpreis']
@@ -593,12 +851,18 @@ def invoice_pdf():
pdf.set_draw_color(0, 0, 0) pdf.set_draw_color(0, 0, 0)
pdf.set_font("Helvetica", "", 10) 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.set_x(left)
pos_label = f"3D-Druck: {projekt}" if projekt else "3D-Druck" pdf.cell(85, 7, pdf_text(positions_label(pos, nummer, projekt,
pdf.cell(85, 7, pos_label) len(calc['positionen']))))
pdf.cell(20, 7, str(stueckzahl), align='R') pdf.cell(20, 7, str(pos['stueckzahl']), align='R')
pdf.cell(30, 7, f"{calc['verkaufspreis']:.2f} EUR", align='R') pdf.cell(30, 7, f"{pos['stueckpreis']:.2f} EUR", align='R')
pdf.cell(30, 7, f"{gesamt_netto:.2f} EUR", new_x=XPos.LMARGIN, new_y=YPos.NEXT, 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) 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']) @app.route('/export', methods=['POST'])
def export(): def export():
"""Exportiert die Kalkulation als TXT-Datei.""" """Exportiert die Kalkulation als TXT-Datei."""
data = request.get_json() data = request.get_json(silent=True) or {}
config = load_config() config = load_config()
merged = {**config, **data} merged = {**config, **data}
calc = calculate(merged) calc = calculate(merged)
lines = [ lines = [
"3D-DRUCK KOSTENKALKULATION", "3D-DRUCK KOSTENKALKULATION",
"=" * 50 + "\n", "=" * 58 + "",
f"Projekt: {data.get('projektName', '')}", "",
f"Datum: {data.get('datum', '')}\n", f"Projekt: {merged.get('projektName', '')}",
"KOSTENAUFSTELLUNG:", f"Datum: {merged.get('datum', '')}",
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",
] ]
satz = mwst_satz_label(calc['mwst_satz'])
if calc.get('mwst_ausweisen'): for nummer, pos in enumerate(calc['positionen'], start=1):
lines.append(f"MwSt. ({satz}%):{'':<{max(0, 16 - len(satz))}}{calc['mwst']:.2f} EUR") label = positions_label(pos, nummer, merged.get('projektName', ''),
lines.append(f"Preis pro Stuck: {calc['gesamt_brutto']:.2f} EUR") 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("") lines.append("")
lines.append(f"Stuckzahl: {calc['stueckzahl']}")
lines.append(f"Auftrag netto: {calc['gesamt_netto']:.2f} EUR") 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'): if calc.get('mwst_ausweisen'):
lines.append(f"MwSt. ({satz}%) gesamt: {calc['gesamt_mwst']:.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']:.2f} EUR") lines.append(f" GESAMTPREIS: {calc['gesamt_endpreis']:>10.2f} EUR")
if calc.get('kleinunternehmer'): if calc.get('kleinunternehmer'):
lines.append("") lines.append("")
lines.append(KLEINUNTERNEHMER_HINWEIS) lines.append(KLEINUNTERNEHMER_HINWEIS)

View File

@@ -10,21 +10,91 @@
let saveTimeout = null; let saveTimeout = null;
let calcTimeout = 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. */ /** Sammelt alle Formularwerte anhand ihrer data-var Attribute. */
function getFormData() { function getFormData() {
const data = {}; const data = {};
document.querySelectorAll('[data-var]').forEach(el => { document.querySelectorAll('[data-var]').forEach(el => {
const key = el.dataset.var; data[el.dataset.var] = feldWert(el);
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;
}); });
const positionen = sammlePositionen();
if (positionen) data.positionen = positionen;
data.dark_mode = document.documentElement.getAttribute('data-bs-theme') === 'dark'; data.dark_mode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
return data; 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. /** Sendet Formulardaten an /calculate und aktualisiert die Ergebnisanzeige.
* Auf Seiten ohne Ergebnisblock (Einstellungen, Rechnung) entfaellt beides. */ * Auf Seiten ohne Ergebnisblock (Einstellungen, Rechnung) entfaellt beides. */
async function recalculate() { async function recalculate() {
@@ -48,64 +118,69 @@ function renderResult(calc) {
const ziel = document.getElementById('result-text'); const ziel = document.getElementById('result-text');
if (!ziel) return; if (!ziel) return;
const eur = (v) => `${String(v.toFixed(2)).padStart(10)} EUR`; 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 = [ calc.positionen.forEach((pos, i) => {
`PRO STUCK`, const titel = pos.name ? pos.name : `Position ${i + 1}`;
`Materialkosten: ${eur(calc.materialkosten)}`, lines.push(`${(i + 1)}. ${titel.toUpperCase()}`);
`Stromkosten: ${eur(calc.stromkosten)}`, lines.push(zeile(' Material:', pos.materialkosten));
`Abschreibung Drucker: ${eur(calc.abschreibung)}`, lines.push(zeile(' Strom:', pos.stromkosten));
`Arbeitskosten: ${eur(calc.arbeitskosten)}`, lines.push(zeile(' Abschreibung:', pos.abschreibung));
`${'\u2500'.repeat(40)}`, lines.push(zeile(' Arbeit:', pos.arbeitskosten));
`Gesamtkosten: ${eur(calc.gesamtkosten_netto)}`, lines.push(zeile(' Kosten je Stuck:', pos.kosten));
'', lines.push(zeile(` Gewinn (${calc.gewinnmarge}%):`, pos.gewinn));
`${marge}${eur(calc.gewinn)}`, lines.push(zeile(' Stueckpreis:', pos.stueckpreis));
`${'\u2500'.repeat(40)}`, lines.push(` ${String(pos.stueckzahl).padStart(3)} x ${pos.stueckpreis.toFixed(2)}` +
`Verkaufspreis (netto): ${eur(calc.verkaufspreis)}`, ` = ${String(pos.zeilensumme.toFixed(2)).padStart(10)} EUR`);
];
if (calc.mwst_ausweisen) {
lines.push(`MwSt. (19%): ${eur(calc.mwst)}`);
}
lines.push(`Preis pro Stuck: ${eur(calc.gesamt_brutto)}`);
// Auftragssumme: bei Stueckzahl 1 identisch, aber explizit ausgewiesen,
// damit der Endbetrag nicht mit dem Stueckpreis verwechselt wird.
lines.push(''); 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) { 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('\u2550'.repeat(40));
lines.push(`GESAMT: ${eur(calc.gesamt_endpreis)}`); lines.push(zeile('GESAMT:', calc.gesamt_endpreis));
ziel.textContent = lines.join('\n'); ziel.textContent = lines.join('\n');
const details = [ const details = [
`Druckzeit: ${calc.druckzeit_stunden} Stunden`, `Druckzeit gesamt: ${calc.druckzeit_stunden} Stunden`,
`Materialgewicht: ${calc.material_kg} kg`, `Materialgewicht gesamt: ${calc.material_kg} kg`,
`Abschreibung pro Stunde: ${calc.abschreibung_pro_stunde} EUR`, `Abschreibung pro Stunde: ${calc.abschreibung_pro_stunde} EUR`,
`Kosten pro Gramm: ${calc.kosten_pro_gramm} EUR`,
`Stromkosten pro Stunde: ${calc.strom_pro_stunde} EUR`, `Stromkosten pro Stunde: ${calc.strom_pro_stunde} EUR`,
]; ];
const detailZiel = document.getElementById('detail-text'); const detailZiel = document.getElementById('detail-text');
if (detailZiel) detailZiel.textContent = details.join('\n'); if (detailZiel) detailZiel.textContent = details.join('\n');
} }
/** Plant ein Auto-Save in die YAML-Konfiguration (debounced 500ms). */ /** Schreibt den aktuellen Formularstand sofort weg. */
function scheduleSave() { async function sofortSpeichern() {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
const data = getFormData();
try { try {
await fetch('/save_config', { await fetch('/save_config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(getFormData()),
}); });
} catch (e) { } catch (e) {
console.error('Save error', e); console.error('Save error', e);
} }
}, 500); }
/** Plant ein Auto-Save in die YAML-Konfiguration (debounced 500ms). */
function scheduleSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(sofortSpeichern, 500);
} }
/** Wird bei jeder Eingabe aufgerufen: speichert + berechnet neu. /** 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. */ /** Zeigt eine kurze Toast-Notification oben rechts an. */
function showNotification(msg) { function showNotification(msg) {
const container = document.getElementById('notification'); 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', () => { 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(); recalculate();
projekteAktualisieren();
}); });

View File

@@ -44,46 +44,38 @@
<form id="calc-form" onchange="onInputChange()" onkeyup="onInputChange()"> <form id="calc-form" onchange="onInputChange()" onkeyup="onInputChange()">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><i class="ti ti-info-circle me-2"></i>Projektinformationen</div> <div class="card-header"><i class="ti ti-info-circle me-2"></i>Projekt</div>
<div class="card-body"> <div class="card-body">
<div class="mb-3">
<label class="form-label">Projektname</label> <label class="form-label">Projektname</label>
<input type="text" class="form-control" data-var="projektName" value="{{ config.projektName }}"> <div class="input-group">
<input type="text" class="form-control" data-var="projektName" id="projekt-name" value="{{ config.projektName }}">
<button type="button" class="btn btn-primary" onclick="projektSpeichern()" title="Projekt unter diesem Namen speichern">
<i class="ti ti-device-floppy me-1"></i>Speichern
</button>
</div> </div>
<div class="mb-0"> <div class="form-text">Übergreifender Name des Auftrags. Die einzelnen Teile werden unten als Positionen erfasst.</div>
<label class="form-label">Stuckzahl</label>
<input type="number" step="1" min="1" class="form-control" data-var="stueckzahl" data-type="number" value="{{ config.stueckzahl }}"> <hr class="my-3">
<label class="form-label">Gespeicherte Projekte</label>
<div class="input-group">
<select class="form-select" id="projekt-auswahl"></select>
<button type="button" class="btn btn-outline-primary" onclick="projektLaden()" title="Ausgewähltes Projekt laden">
<i class="ti ti-folder-open me-1"></i>Laden
</button>
<button type="button" class="btn btn-outline-danger" onclick="projektLoeschen()" title="Ausgewähltes Projekt löschen">
<i class="ti ti-trash"></i>
</button>
</div> </div>
<div class="form-text" id="projekt-info">Noch keine Projekte gespeichert.</div>
</div> </div>
</div> </div>
<div class="card mb-3"> <div id="positionen-liste"></div>
<div class="card-header"><i class="ti ti-box me-2"></i>Material</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Gewicht (Gramm)</label>
<input type="number" step="0.01" class="form-control" data-var="materialGewicht" data-type="number" value="{{ config.materialGewicht }}">
</div>
<div class="mb-3">
<label class="form-label">Preis pro kg (EUR)</label>
<input type="number" step="0.01" class="form-control" data-var="materialPreisProKg" data-type="number" value="{{ config.materialPreisProKg }}">
</div>
</div>
</div>
<div class="card mb-3"> <button type="button" class="btn btn-outline-primary w-100 mb-3" onclick="positionHinzufuegen()">
<div class="card-header"><i class="ti ti-printer me-2"></i>Druckparameter</div> <i class="ti ti-plus me-1"></i>Position hinzufügen
<div class="card-body"> </button>
<div class="mb-3">
<label class="form-label">Druckzeit (Minuten)</label>
<input type="number" step="1" class="form-control" data-var="druckzeit" data-type="number" value="{{ config.druckzeit }}">
</div>
<div class="mb-0">
<label class="form-label">Arbeitszeit (Minuten)</label>
<input type="number" step="1" class="form-control" data-var="arbeitszeit" data-type="number" value="{{ config.arbeitszeit }}">
</div>
</div>
</div>
</form> </form>
</div> </div>
@@ -123,6 +115,47 @@
</div> </div>
<template id="position-vorlage">
<div class="card mb-3 position-karte">
<div class="card-header d-flex align-items-center">
<i class="ti ti-box me-2"></i>
<span class="flex-fill">Position <span class="position-nummer"></span></span>
<button type="button" class="btn btn-sm btn-ghost-danger position-entfernen" title="Position entfernen">
<i class="ti ti-trash"></i>
</button>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Bezeichnung</label>
<input type="text" class="form-control" data-pos-var="name" placeholder="z.B. Halterung links">
</div>
<div class="col-md-6">
<label class="form-label">Gewicht (Gramm)</label>
<input type="number" step="0.01" min="0" class="form-control" data-pos-var="materialGewicht" data-type="number">
</div>
<div class="col-md-6">
<label class="form-label">Preis pro kg (EUR)</label>
<input type="number" step="0.01" min="0" class="form-control" data-pos-var="materialPreisProKg" data-type="number">
</div>
<div class="col-md-4">
<label class="form-label">Druckzeit (Min.)</label>
<input type="number" step="1" min="0" class="form-control" data-pos-var="druckzeit" data-type="number">
</div>
<div class="col-md-4">
<label class="form-label">Arbeitszeit (Min.)</label>
<input type="number" step="1" min="0" class="form-control" data-pos-var="arbeitszeit" data-type="number">
</div>
<div class="col-md-4">
<label class="form-label">Stückzahl</label>
<input type="number" step="1" min="1" class="form-control" data-pos-var="stueckzahl" data-type="number">
</div>
</div>
</div>
</div>
</template>
<script id="positionen-daten" type="application/json">{{ config.positionen|tojson }}</script>
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script> <script src="{{ url_for('static', filename='script.js') }}"></script>
</body> </body>

View File

@@ -74,22 +74,17 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><i class="ti ti-eye me-2"></i>Vorschau</div> <div class="card-header"><i class="ti ti-eye me-2"></i>Vorschau</div>
<div class="card-body p-0"> <div class="card-body p-0">
{% set stueckzahl = calc.stueckzahl %}
{% set gesamt_netto = calc.gesamt_netto %}
{% set gesamt_mwst = calc.gesamt_mwst %}
{% set gesamt_brutto = calc.gesamt_endpreis %}
<div class="p-3" style="font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.8;white-space:pre;overflow-x:auto;background:var(--tblr-bg-surface);">LEISTUNGSBESCHREIBUNG: <div class="p-3" style="font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.8;white-space:pre;overflow-x:auto;background:var(--tblr-bg-surface);">LEISTUNGSBESCHREIBUNG:
{{ '\u2500' * 50 }} {{ '\u2500' * 58 }}
Position: 3D-Druck - {{ config.projektName }} {% for pos in calc.positionen -%}
Menge: {{ stueckzahl }} {{ loop.index }}. {{ pos.name if pos.name else (config.projektName if calc.anzahl_positionen == 1 else 'Position ' ~ loop.index) }}
Einzelpreis: {{ "%.2f"|format(calc.verkaufspreis) }} EUR {{ pos.stueckzahl }} x {{ "%.2f"|format(pos.stueckpreis) }} EUR = {{ "%.2f"|format(pos.zeilensumme) }} EUR
Gesamt: {{ "%.2f"|format(gesamt_netto) }} EUR {% endfor %}
{{ '\u2500' * 58 }}
{{ '\u2500' * 50 }} Nettobetrag: {{ "%.2f"|format(calc.gesamt_netto) }} EUR
Nettobetrag: {{ "%.2f"|format(gesamt_netto) }} EUR {% if calc.mwst_ausweisen %}MwSt. ({{ '%g'|format(calc.mwst_satz) }}%): {{ "%.2f"|format(calc.gesamt_mwst) }} EUR
{% if calc.mwst_ausweisen %}MwSt. ({{ '%g'|format(calc.mwst_satz) }}%): {{ "%.2f"|format(gesamt_mwst) }} EUR {% endif %}{{ '\u2550' * 58 }}
{% endif %}{{ '\u2550' * 50 }} GESAMTBETRAG{% if calc.mwst_ausweisen %} (brutto){% endif %}: {{ "%.2f"|format(calc.gesamt_endpreis) }} EUR
GESAMTBETRAG{% if calc.mwst_ausweisen %} (brutto){% endif %}: {{ "%.2f"|format(gesamt_brutto) }} EUR
{% if calc.kleinunternehmer %} {% if calc.kleinunternehmer %}
{{ kleinunternehmer_hinweis }} {{ kleinunternehmer_hinweis }}
{% elif calc.privat %} {% elif calc.privat %}