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:
448
app.py
448
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: <Projekt>". Sobald mehrere Positionen im Spiel sind,
|
||||
braucht jede Zeile eine eigene, unterscheidbare Bezeichnung.
|
||||
"""
|
||||
name = (pos.get('name') or '').strip()
|
||||
if name:
|
||||
return f"3D-Druck: {name}"
|
||||
if anzahl == 1:
|
||||
return f"3D-Druck: {projekt}" if projekt else "3D-Druck"
|
||||
return f"3D-Druck: Position {nummer}"
|
||||
|
||||
|
||||
def berechne_position(pos, data, abschreibung_pro_stunde):
|
||||
"""Berechnet eine einzelne Position.
|
||||
|
||||
'data' liefert die auftragsweiten Werte (Strom, Stundenlohn, Marge),
|
||||
'pos' die positionsspezifischen (Material, Zeiten, Stueckzahl).
|
||||
"""
|
||||
druckzeit_stunden = float(pos.get('druckzeit', 0)) / 60
|
||||
arbeitszeit_stunden = float(pos.get('arbeitszeit', 0)) / 60
|
||||
material_kg = float(pos.get('materialGewicht', 0)) / 1000
|
||||
material_preis_pro_kg = float(pos.get('materialPreisProKg', 0))
|
||||
|
||||
materialkosten = material_kg * material_preis_pro_kg
|
||||
stromkosten = (druckzeit_stunden * float(data.get('stromverbrauch', 0))
|
||||
* float(data.get('strompreis', 0)))
|
||||
abschreibung = abschreibung_pro_stunde * druckzeit_stunden
|
||||
arbeitskosten = arbeitszeit_stunden * float(data.get('stundenlohn', 0))
|
||||
|
||||
kosten = materialkosten + stromkosten + abschreibung + arbeitskosten
|
||||
gewinn = kosten * (float(data.get('gewinnmarge', 0)) / 100)
|
||||
verkaufspreis = kosten + gewinn
|
||||
|
||||
# Auf Cent gerundeter Stueckpreis ist die Basis der Zeilensumme,
|
||||
# damit die Rechnungszeile nachrechenbar bleibt.
|
||||
stueckzahl = as_stueckzahl(pos.get('stueckzahl', 1))
|
||||
stueckpreis = round(verkaufspreis, 2)
|
||||
zeilensumme = round(stueckpreis * stueckzahl, 2)
|
||||
|
||||
return {
|
||||
'name': str(pos.get('name', '') or ''),
|
||||
'stueckzahl': stueckzahl,
|
||||
'gewinnmarge': float(data.get('gewinnmarge', 0)),
|
||||
'steuerregime': regime,
|
||||
'mwst_satz': mwst_satz,
|
||||
'kleinunternehmer': regime == 'kleinunternehmer',
|
||||
'privat': regime == 'privat',
|
||||
# Steuernummer/USt-IdNr. gehoeren nur auf einen Unternehmer-Beleg.
|
||||
'ist_unternehmer': regime != 'privat',
|
||||
'gesamt_netto': gesamt_netto,
|
||||
'gesamt_mwst': gesamt_mwst,
|
||||
'gesamt_endpreis': gesamt_endpreis,
|
||||
'materialkosten': round(materialkosten, 2),
|
||||
'stromkosten': round(stromkosten, 2),
|
||||
'abschreibung': round(abschreibung, 2),
|
||||
'arbeitskosten': round(arbeitskosten, 2),
|
||||
'gesamtkosten_netto': round(gesamtkosten_netto, 2),
|
||||
'kosten': round(kosten, 2),
|
||||
'gewinn': round(gewinn, 2),
|
||||
'verkaufspreis': round(verkaufspreis, 2),
|
||||
'mwst': round(mwst, 2),
|
||||
'mwst_ausweisen': mwst_ausweisen,
|
||||
'gesamt_brutto': round(gesamt_brutto, 2),
|
||||
'stueckpreis': stueckpreis,
|
||||
'zeilensumme': zeilensumme,
|
||||
'druckzeit_stunden': round(druckzeit_stunden, 2),
|
||||
'arbeitszeit_stunden': round(arbeitszeit_stunden, 2),
|
||||
'material_kg': round(material_kg, 3),
|
||||
'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4),
|
||||
'kosten_pro_gramm': round(material_preis_pro_kg / 1000, 3),
|
||||
'strom_pro_stunde': round(float(data.get('stromverbrauch', 0)) * float(data.get('strompreis', 0)), 3),
|
||||
}
|
||||
|
||||
|
||||
def calculate(data):
|
||||
"""Berechnet alle Positionen und die Auftragssummen.
|
||||
|
||||
Rueckgabe: Dict mit der Positionsliste, den Summen und den
|
||||
auftragsweiten Detailwerten.
|
||||
"""
|
||||
anschaffung = float(data.get('druckerAnschaffung', 0))
|
||||
nutzung = float(data.get('druckerNutzungsdauer', 1))
|
||||
betrieb = float(data.get('druckerBetriebsstunden', 1))
|
||||
abschreibung_pro_stunde = (anschaffung / (nutzung * betrieb)
|
||||
if (nutzung * betrieb) > 0 else 0)
|
||||
|
||||
positionen = saubere_positionen(data.get('positionen'))
|
||||
berechnet = [berechne_position(p, data, abschreibung_pro_stunde)
|
||||
for p in positionen]
|
||||
|
||||
regime, mwst_ausweisen, mwst_satz, mwst_faktor = steuer_kontext(data)
|
||||
|
||||
# Summe der bereits gerundeten Zeilen -- so stimmt die Rechnung
|
||||
# auch dann, wenn der Kunde sie Zeile fuer Zeile nachrechnet.
|
||||
netto = round(sum(p['zeilensumme'] for p in berechnet), 2)
|
||||
mwst = round(netto * mwst_faktor, 2)
|
||||
brutto = round(netto + mwst, 2)
|
||||
|
||||
return {
|
||||
'positionen': berechnet,
|
||||
'anzahl_positionen': len(berechnet),
|
||||
'teile_gesamt': sum(p['stueckzahl'] for p in berechnet),
|
||||
'gesamt_netto': netto,
|
||||
'gesamt_mwst': mwst,
|
||||
'gesamt_endpreis': brutto,
|
||||
# Auftragsweite Kostensummen (fuer die Aufstellung im Frontend)
|
||||
'summe_materialkosten': round(sum(p['materialkosten'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'summe_stromkosten': round(sum(p['stromkosten'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'summe_abschreibung': round(sum(p['abschreibung'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'summe_arbeitskosten': round(sum(p['arbeitskosten'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'summe_kosten': round(sum(p['kosten'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'summe_gewinn': round(sum(p['gewinn'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'gewinnmarge': float(data.get('gewinnmarge', 0)),
|
||||
'steuerregime': regime,
|
||||
'mwst_satz': mwst_satz,
|
||||
'mwst_ausweisen': mwst_ausweisen,
|
||||
'kleinunternehmer': regime == 'kleinunternehmer',
|
||||
'privat': regime == 'privat',
|
||||
# Steuernummer/USt-IdNr. gehoeren nur auf einen Unternehmer-Beleg.
|
||||
'ist_unternehmer': regime != 'privat',
|
||||
'druckzeit_stunden': round(sum(p['druckzeit_stunden'] * p['stueckzahl'] for p in berechnet), 2),
|
||||
'material_kg': round(sum(p['material_kg'] * p['stueckzahl'] for p in berechnet), 3),
|
||||
'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4),
|
||||
'strom_pro_stunde': round(float(data.get('stromverbrauch', 0))
|
||||
* float(data.get('strompreis', 0)), 3),
|
||||
}
|
||||
|
||||
|
||||
@@ -373,6 +539,10 @@ def save_config_route():
|
||||
# Nur bekannte Schluessel uebernehmen -- sonst kann ein beliebiger
|
||||
# Client die Config mit fremden Feldern vollschreiben.
|
||||
gefiltert = {k: v for k, v in data.items() if k in DEFAULT_VALUES}
|
||||
# Positionen sind eine Liste von Dicts -- vor dem Speichern
|
||||
# normalisieren, damit kein Client-Muell in der Config landet.
|
||||
if 'positionen' in gefiltert:
|
||||
gefiltert['positionen'] = saubere_positionen(gefiltert['positionen'])
|
||||
with _config_lock:
|
||||
existing = load_config()
|
||||
existing.update(gefiltert)
|
||||
@@ -383,6 +553,95 @@ def save_config_route():
|
||||
return jsonify({'status': 'ok', 'gespeichert': len(gefiltert)})
|
||||
|
||||
|
||||
@app.route('/projekte')
|
||||
def projekte_liste():
|
||||
"""Liefert die gespeicherten Projekte als JSON, neueste zuerst."""
|
||||
projekte = lade_projekte()
|
||||
config = load_config()
|
||||
eintraege = []
|
||||
for name, daten in projekte.items():
|
||||
if not isinstance(daten, dict):
|
||||
continue
|
||||
positionen = saubere_positionen(daten.get('positionen'))
|
||||
# Preis mit den AKTUELLEN Betriebswerten rechnen, nicht mit den
|
||||
# Werten von damals -- die Marge liegt bewusst nicht im Projekt.
|
||||
calc = calculate({**config, 'positionen': positionen})
|
||||
eintraege.append({
|
||||
'name': name,
|
||||
'geaendert': daten.get('geaendert', ''),
|
||||
'kundenName': daten.get('kundenName', ''),
|
||||
'anzahl_positionen': calc['anzahl_positionen'],
|
||||
'teile_gesamt': calc['teile_gesamt'],
|
||||
'gesamt_endpreis': calc['gesamt_endpreis'],
|
||||
})
|
||||
eintraege.sort(key=lambda e: e['geaendert'], reverse=True)
|
||||
return jsonify({'projekte': eintraege, 'aktuell': config.get('projektName', '')})
|
||||
|
||||
|
||||
@app.route('/projekte/speichern', methods=['POST'])
|
||||
def projekt_speichern():
|
||||
"""Sichert den aktuellen Arbeitsstand unter seinem Projektnamen."""
|
||||
daten = request.get_json(silent=True) or {}
|
||||
with _config_lock:
|
||||
config = load_config()
|
||||
name = projekt_name(daten.get('name') or config.get('projektName'))
|
||||
if not name:
|
||||
return jsonify({'error': 'Bitte zuerst einen Projektnamen vergeben'}), 400
|
||||
|
||||
with _projekte_lock:
|
||||
projekte = lade_projekte()
|
||||
neu_angelegt = name not in projekte
|
||||
if neu_angelegt and len(projekte) >= MAX_PROJEKTE:
|
||||
return jsonify({'error': f'Maximal {MAX_PROJEKTE} Projekte'}), 400
|
||||
eintrag = projekt_aus_config(config)
|
||||
eintrag['projektName'] = name
|
||||
eintrag['geaendert'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
projekte[name] = eintrag
|
||||
speichere_projekte(projekte)
|
||||
|
||||
# Der Arbeitsstand traegt danach denselben Namen wie das Projekt.
|
||||
with _config_lock:
|
||||
config = load_config()
|
||||
config['projektName'] = name
|
||||
save_config(_migriere(config))
|
||||
return jsonify({'status': 'ok', 'name': name, 'neu': neu_angelegt})
|
||||
|
||||
|
||||
@app.route('/projekte/laden', methods=['POST'])
|
||||
def projekt_laden():
|
||||
"""Laedt ein gespeichertes Projekt in den Arbeitsstand."""
|
||||
daten = request.get_json(silent=True) or {}
|
||||
name = projekt_name(daten.get('name'))
|
||||
with _projekte_lock:
|
||||
projekte = lade_projekte()
|
||||
eintrag = projekte.get(name)
|
||||
if not isinstance(eintrag, dict):
|
||||
return jsonify({'error': f'Projekt "{name}" nicht gefunden'}), 404
|
||||
|
||||
with _config_lock:
|
||||
config = load_config()
|
||||
for feld in PROJEKT_FELDER:
|
||||
if feld in eintrag:
|
||||
config[feld] = eintrag[feld]
|
||||
config['projektName'] = name
|
||||
save_config(_migriere(config))
|
||||
return jsonify({'status': 'ok', 'name': name})
|
||||
|
||||
|
||||
@app.route('/projekte/loeschen', methods=['POST'])
|
||||
def projekt_loeschen():
|
||||
"""Entfernt ein gespeichertes Projekt. Der Arbeitsstand bleibt."""
|
||||
daten = request.get_json(silent=True) or {}
|
||||
name = projekt_name(daten.get('name'))
|
||||
with _projekte_lock:
|
||||
projekte = lade_projekte()
|
||||
if name not in projekte:
|
||||
return jsonify({'error': f'Projekt "{name}" nicht gefunden'}), 404
|
||||
del projekte[name]
|
||||
speichere_projekte(projekte)
|
||||
return jsonify({'status': 'ok', 'name': name})
|
||||
|
||||
|
||||
@app.route('/upload_logo', methods=['POST'])
|
||||
def upload_logo():
|
||||
"""Logo-Datei hochladen, auf gueltiges Bild pruefen und speichern."""
|
||||
@@ -454,7 +713,6 @@ def invoice_pdf():
|
||||
data = request.form
|
||||
config = load_config()
|
||||
calc = calculate(config)
|
||||
stueckzahl = calc['stueckzahl']
|
||||
gesamt_netto = calc['gesamt_netto']
|
||||
gesamt_mwst = calc['gesamt_mwst']
|
||||
gesamt_brutto = calc['gesamt_endpreis']
|
||||
@@ -593,12 +851,18 @@ def invoice_pdf():
|
||||
pdf.set_draw_color(0, 0, 0)
|
||||
|
||||
pdf.set_font("Helvetica", "", 10)
|
||||
pdf.set_x(left)
|
||||
pos_label = f"3D-Druck: {projekt}" if projekt else "3D-Druck"
|
||||
pdf.cell(85, 7, pos_label)
|
||||
pdf.cell(20, 7, str(stueckzahl), align='R')
|
||||
pdf.cell(30, 7, f"{calc['verkaufspreis']:.2f} EUR", align='R')
|
||||
pdf.cell(30, 7, f"{gesamt_netto:.2f} EUR", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
|
||||
for nummer, pos in enumerate(calc['positionen'], start=1):
|
||||
# Seitenumbruch, bevor die Zeile in den Fussbereich laufen wuerde.
|
||||
if pdf.get_y() > 250:
|
||||
pdf.add_page()
|
||||
pdf.set_y(20)
|
||||
pdf.set_x(left)
|
||||
pdf.cell(85, 7, pdf_text(positions_label(pos, nummer, projekt,
|
||||
len(calc['positionen']))))
|
||||
pdf.cell(20, 7, str(pos['stueckzahl']), align='R')
|
||||
pdf.cell(30, 7, f"{pos['stueckpreis']:.2f} EUR", align='R')
|
||||
pdf.cell(30, 7, f"{pos['zeilensumme']:.2f} EUR",
|
||||
new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
|
||||
|
||||
pdf.line(left, pdf.get_y() + 0.5, 195, pdf.get_y() + 0.5)
|
||||
|
||||
@@ -642,35 +906,51 @@ def invoice_pdf():
|
||||
@app.route('/export', methods=['POST'])
|
||||
def export():
|
||||
"""Exportiert die Kalkulation als TXT-Datei."""
|
||||
data = request.get_json()
|
||||
data = request.get_json(silent=True) or {}
|
||||
config = load_config()
|
||||
merged = {**config, **data}
|
||||
calc = calculate(merged)
|
||||
|
||||
lines = [
|
||||
"3D-DRUCK KOSTENKALKULATION",
|
||||
"=" * 50 + "\n",
|
||||
f"Projekt: {data.get('projektName', '')}",
|
||||
f"Datum: {data.get('datum', '')}\n",
|
||||
"KOSTENAUFSTELLUNG:",
|
||||
f"Materialkosten: {calc['materialkosten']:.2f} EUR",
|
||||
f"Stromkosten: {calc['stromkosten']:.2f} EUR",
|
||||
f"Abschreibung: {calc['abschreibung']:.2f} EUR",
|
||||
f"Arbeitskosten: {calc['arbeitskosten']:.2f} EUR",
|
||||
f"Gesamtkosten: {calc['gesamtkosten_netto']:.2f} EUR",
|
||||
f"Gewinn: {calc['gewinn']:.2f} EUR",
|
||||
f"Verkaufspreis (netto): {calc['verkaufspreis']:.2f} EUR",
|
||||
"=" * 58 + "",
|
||||
"",
|
||||
f"Projekt: {merged.get('projektName', '')}",
|
||||
f"Datum: {merged.get('datum', '')}",
|
||||
"",
|
||||
]
|
||||
|
||||
for nummer, pos in enumerate(calc['positionen'], start=1):
|
||||
label = positions_label(pos, nummer, merged.get('projektName', ''),
|
||||
calc['anzahl_positionen'])
|
||||
lines.append(f"POSITION {nummer}: {label}")
|
||||
lines.append("-" * 58)
|
||||
lines.append(f" Materialkosten: {pos['materialkosten']:>10.2f} EUR")
|
||||
lines.append(f" Stromkosten: {pos['stromkosten']:>10.2f} EUR")
|
||||
lines.append(f" Abschreibung: {pos['abschreibung']:>10.2f} EUR")
|
||||
lines.append(f" Arbeitskosten: {pos['arbeitskosten']:>10.2f} EUR")
|
||||
lines.append(f" Kosten je Stuck: {pos['kosten']:>10.2f} EUR")
|
||||
lines.append(f" Gewinn je Stuck: {pos['gewinn']:>10.2f} EUR")
|
||||
lines.append(f" Stueckpreis (netto): {pos['stueckpreis']:>10.2f} EUR")
|
||||
lines.append(f" Stueckzahl: {pos['stueckzahl']:>10}")
|
||||
lines.append(f" Zeilensumme: {pos['zeilensumme']:>10.2f} EUR")
|
||||
lines.append("")
|
||||
|
||||
satz = mwst_satz_label(calc['mwst_satz'])
|
||||
lines.append("=" * 58)
|
||||
lines.append(f"AUFTRAG ({calc['anzahl_positionen']} Positionen, "
|
||||
f"{calc['teile_gesamt']} Teile)")
|
||||
lines.append(f" Materialkosten gesamt: {calc['summe_materialkosten']:>10.2f} EUR")
|
||||
lines.append(f" Stromkosten gesamt: {calc['summe_stromkosten']:>10.2f} EUR")
|
||||
lines.append(f" Abschreibung gesamt: {calc['summe_abschreibung']:>10.2f} EUR")
|
||||
lines.append(f" Arbeitskosten gesamt: {calc['summe_arbeitskosten']:>10.2f} EUR")
|
||||
lines.append(f" Kosten gesamt: {calc['summe_kosten']:>10.2f} EUR")
|
||||
lines.append(f" Gewinn gesamt: {calc['summe_gewinn']:>10.2f} EUR")
|
||||
lines.append("-" * 58)
|
||||
lines.append(f" Auftrag netto: {calc['gesamt_netto']:>10.2f} EUR")
|
||||
if calc.get('mwst_ausweisen'):
|
||||
lines.append(f"MwSt. ({satz}%):{'':<{max(0, 16 - len(satz))}}{calc['mwst']:.2f} EUR")
|
||||
lines.append(f"Preis pro Stuck: {calc['gesamt_brutto']:.2f} EUR")
|
||||
lines.append("")
|
||||
lines.append(f"Stuckzahl: {calc['stueckzahl']}")
|
||||
lines.append(f"Auftrag netto: {calc['gesamt_netto']:.2f} EUR")
|
||||
if calc.get('mwst_ausweisen'):
|
||||
lines.append(f"MwSt. ({satz}%) gesamt: {calc['gesamt_mwst']:.2f} EUR")
|
||||
lines.append(f"GESAMTPREIS: {calc['gesamt_endpreis']:.2f} EUR")
|
||||
lines.append(f" MwSt. ({satz}%):{'':<{max(1, 16 - len(satz))}}{calc['gesamt_mwst']:>10.2f} EUR")
|
||||
lines.append(f" GESAMTPREIS: {calc['gesamt_endpreis']:>10.2f} EUR")
|
||||
if calc.get('kleinunternehmer'):
|
||||
lines.append("")
|
||||
lines.append(KLEINUNTERNEHMER_HINWEIS)
|
||||
|
||||
Reference in New Issue
Block a user