461 lines
15 KiB
Python
461 lines
15 KiB
Python
"""
|
|
3D-Druck Kostenkalkulation — Flask Web-App.
|
|
|
|
Ersetzt die alte Tkinter-Version (3dDruckKalkulator.py).
|
|
Bietet eine Weboberflaeche zur Eingabe von Druckparametern,
|
|
Live-Berechnung der Kosten, PDF-Rechnung und TXT-Export.
|
|
"""
|
|
import os
|
|
import struct
|
|
import yaml
|
|
import io
|
|
from datetime import datetime
|
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file
|
|
from fpdf import FPDF
|
|
|
|
app = Flask(__name__)
|
|
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024
|
|
CONFIG_FILE = 'druckkalkulation_config.yaml'
|
|
|
|
|
|
def image_dimensions(filepath):
|
|
"""Liest Pixelbreite und -hoehe aus PNG/JPEG/WebP ohne externe Libs."""
|
|
with open(filepath, 'rb') as f:
|
|
head = f.read(64)
|
|
if head[:8] == b'\x89PNG\r\n\x1a\n':
|
|
w, h = struct.unpack('>II', head[16:24])
|
|
return w, h
|
|
if head[:2] == b'\xff\xd8':
|
|
i = 2
|
|
while i < len(head) - 1:
|
|
if head[i] != 0xFF:
|
|
break
|
|
marker = head[i+1]
|
|
if marker in (0xC0, 0xC1, 0xC2):
|
|
h, w = struct.unpack('>HH', head[i+5:i+9])
|
|
return w, h
|
|
length = struct.unpack('>H', head[i+2:i+4])[0]
|
|
i += 2 + length
|
|
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
|
|
if head[12:16] == b'VP8 ':
|
|
w, h = struct.unpack('<HH', head[26:30])
|
|
return (w & 0x3FFF) * 2, (h & 0x3FFF) * 2
|
|
if head[12:16] == b'VP8L':
|
|
bits = struct.unpack('<I', head[21:25])[0]
|
|
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
return None, None
|
|
LOGO_FILE = 'static/logo.png'
|
|
|
|
DEFAULT_VALUES = {
|
|
'projektName': '',
|
|
'kundenName': '',
|
|
'kundenAdresse': '',
|
|
'materialGewicht': 0,
|
|
'materialPreisProKg': 25,
|
|
'druckzeit': 0,
|
|
'stromverbrauch': 0.15,
|
|
'strompreis': 0.35,
|
|
'druckerAnschaffung': 500,
|
|
'druckerNutzungsdauer': 3,
|
|
'druckerBetriebsstunden': 2000,
|
|
'arbeitszeit': 0,
|
|
'stundenlohn': 25,
|
|
'gewinnmarge': 20,
|
|
'stueckzahl': 1,
|
|
'mwst_ausweisen': False,
|
|
'rechnungsnummer': '',
|
|
'datum': datetime.now().strftime('%Y-%m-%d'),
|
|
'dark_mode': False,
|
|
'firmenName': '',
|
|
'firmenAdresse': '',
|
|
'telefon': '',
|
|
'email': '',
|
|
'paypal': '',
|
|
'iban': '',
|
|
'bankName': '',
|
|
'logo': '',
|
|
}
|
|
|
|
|
|
def load_config():
|
|
"""Liest die YAML-Konfiguration von Disk. Fehlt die Datei,
|
|
werden DEFAULT_VALUES verwendet."""
|
|
try:
|
|
if os.path.exists(CONFIG_FILE):
|
|
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
|
data = yaml.safe_load(f)
|
|
if data:
|
|
for k in DEFAULT_VALUES:
|
|
if k not in data:
|
|
data[k] = DEFAULT_VALUES[k]
|
|
return data
|
|
except Exception as e:
|
|
print(f"Fehler beim Laden: {e}")
|
|
return dict(DEFAULT_VALUES)
|
|
|
|
|
|
def save_config(data):
|
|
"""Schreibt das uebergebene Dict als YAML-Datei."""
|
|
try:
|
|
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
|
except Exception as e:
|
|
print(f"Fehler beim Speichern: {e}")
|
|
|
|
|
|
def calculate(data):
|
|
"""Berechnet alle Kosten aus den Formularwerten.
|
|
|
|
Rueckgabe: Dict mit Einzelposten, Gesamtsummen und Detailwerten.
|
|
"""
|
|
# 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
|
|
|
|
# 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
|
|
|
|
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
|
|
mwst_ausweisen = str(data.get('mwst_ausweisen', '')).lower() in ('true', '1', 'yes')
|
|
mwst = verkaufspreis * 0.19 if mwst_ausweisen else 0
|
|
gesamt_brutto = verkaufspreis + mwst
|
|
|
|
material_preis_pro_kg = float(data.get('materialPreisProKg', 0))
|
|
|
|
return {
|
|
'materialkosten': round(materialkosten, 2),
|
|
'stromkosten': round(stromkosten, 2),
|
|
'abschreibung': round(abschreibung, 2),
|
|
'arbeitskosten': round(arbeitskosten, 2),
|
|
'gesamtkosten_netto': round(gesamtkosten_netto, 2),
|
|
'gewinn': round(gewinn, 2),
|
|
'verkaufspreis': round(verkaufspreis, 2),
|
|
'mwst': round(mwst, 2),
|
|
'mwst_ausweisen': mwst_ausweisen,
|
|
'gesamt_brutto': round(gesamt_brutto, 2),
|
|
'druckzeit_stunden': round(druckzeit_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),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Hauptseite mit Eingabeformular und Ergebnissen."""
|
|
config = load_config()
|
|
calc = calculate(config)
|
|
return render_template('index.html', config=config, calc=calc)
|
|
|
|
|
|
@app.route('/settings')
|
|
def settings():
|
|
"""Einstellungsseite fuer Druckerabschreibung, Arbeitskosten und Marge."""
|
|
config = load_config()
|
|
return render_template('settings.html', config=config)
|
|
|
|
|
|
@app.route('/calculate', methods=['POST'])
|
|
def calc_json():
|
|
"""AJAX-Endpunkt: Nimmt Formular-JSON entgegen, gibt Berechnung zurueck.
|
|
Fehlende Werte werden aus der gespeicherten Config ergaenzt."""
|
|
data = request.get_json()
|
|
config = load_config()
|
|
merged = {**config, **data}
|
|
calc = calculate(merged)
|
|
return jsonify(calc)
|
|
|
|
|
|
@app.route('/save_config', methods=['POST'])
|
|
def save_config_route():
|
|
"""AJAX-Endpunkt: Speichert aktuelle Formularwerte in YAML.
|
|
Vorhandene Werte (z.B. aus Einstellungen) werden nicht ueberschrieben."""
|
|
data = request.get_json()
|
|
existing = load_config()
|
|
existing.update(data)
|
|
save_config(existing)
|
|
return jsonify({'status': 'ok'})
|
|
|
|
|
|
@app.route('/upload_logo', methods=['POST'])
|
|
def upload_logo():
|
|
"""Logo-Datei hochladen, auf gueltiges Bild pruefen und speichern."""
|
|
if 'logo' not in request.files:
|
|
return jsonify({'error': 'Keine Datei'}), 400
|
|
file = request.files['logo']
|
|
if file.filename == '':
|
|
return jsonify({'error': 'Keine Datei ausgewaehlt'}), 400
|
|
|
|
raw = file.read()
|
|
# Magic-Bytes-Pruefung
|
|
if raw[:8] == b'\x89PNG\r\n\x1a\n':
|
|
ext = 'png'
|
|
elif raw[:2] in (b'\xff\xd8',):
|
|
ext = 'jpg'
|
|
elif raw[:4] == b'RIFF' and raw[8:12] == b'WEBP':
|
|
ext = 'webp'
|
|
else:
|
|
return jsonify({'error': 'Nur PNG, JPEG oder WebP erlaubt'}), 400
|
|
|
|
filename = f'logo.{ext}'
|
|
filepath = os.path.join('static', filename)
|
|
with open(filepath, 'wb') as f:
|
|
f.write(raw)
|
|
|
|
config = load_config()
|
|
config['logo'] = filename
|
|
save_config(config)
|
|
return jsonify({'status': 'ok', 'filename': filename})
|
|
|
|
|
|
@app.route('/delete_logo', methods=['POST'])
|
|
def delete_logo():
|
|
"""Logo loeschen."""
|
|
config = load_config()
|
|
logo_name = config.get('logo', '')
|
|
if logo_name:
|
|
logo_path = os.path.join('static', logo_name)
|
|
if os.path.exists(logo_path):
|
|
os.remove(logo_path)
|
|
config['logo'] = ''
|
|
save_config(config)
|
|
return jsonify({'status': 'ok'})
|
|
|
|
|
|
@app.route('/invoice')
|
|
def invoice():
|
|
"""Rechnungsseite mit Kundendaten und Vorschau."""
|
|
config = load_config()
|
|
calc = calculate(config)
|
|
return render_template('invoice.html', config=config, calc=calc)
|
|
|
|
|
|
@app.route('/invoice/pdf', methods=['POST'])
|
|
def invoice_pdf():
|
|
"""Generiert eine PDF-Rechnung via fpdf2 und sendet sie zum Download."""
|
|
data = request.form
|
|
config = load_config()
|
|
calc = calculate(config)
|
|
stueckzahl = int(config.get('stueckzahl', 1))
|
|
gesamt_netto = round(calc['verkaufspreis'] * stueckzahl, 2)
|
|
gesamt_mwst = round(gesamt_netto * 0.19, 2) if calc.get('mwst_ausweisen') else 0
|
|
gesamt_brutto = round(gesamt_netto + gesamt_mwst, 2)
|
|
|
|
firmen_name = config.get('firmenName', '')
|
|
firmen_adresse = config.get('firmenAdresse', '')
|
|
telefon = config.get('telefon', '')
|
|
email = config.get('email', '')
|
|
paypal = config.get('paypal', '')
|
|
iban = config.get('iban', '')
|
|
bank_name = config.get('bankName', '')
|
|
|
|
kunde = data.get('kundenName', config.get('kundenName', ''))
|
|
adresse = data.get('kundenAdresse', config.get('kundenAdresse', ''))
|
|
rechnungsnummer = data.get('rechnungsnummer', config.get('rechnungsnummer', ''))
|
|
datum = data.get('datum', config.get('datum', ''))
|
|
projekt = config.get('projektName', '')
|
|
|
|
pdf = FPDF()
|
|
pdf.set_margins(15, 10, 15)
|
|
pdf.add_page()
|
|
|
|
left = 15
|
|
col_w = 45
|
|
|
|
# Logo rechts oben
|
|
logo_name = config.get('logo', '')
|
|
logo_path = os.path.join('static', logo_name) if logo_name else ''
|
|
if logo_name and os.path.exists(logo_path):
|
|
pdf.image(logo_path, x=155, y=8, w=40)
|
|
img_w, img_h = image_dimensions(logo_path)
|
|
if img_w and img_h:
|
|
logo_bottom = 8 + 40 * (img_h / img_w)
|
|
else:
|
|
logo_bottom = 8 + 20
|
|
else:
|
|
pdf.set_font("Arial", "B", 18)
|
|
pdf.set_xy(150, 15)
|
|
pdf.set_text_color(6, 111, 209)
|
|
pdf.cell(col_w, 7, "JET", ln=True, align='R')
|
|
pdf.set_x(150)
|
|
pdf.cell(col_w, 7, "Design", ln=True, align='R')
|
|
pdf.set_text_color(0, 0, 0)
|
|
logo_bottom = pdf.get_y()
|
|
|
|
# Firmendaten rechts unter Logo
|
|
right_x = 150
|
|
info_y = logo_bottom + 3
|
|
pdf.set_font("Arial", "B", 9)
|
|
pdf.set_xy(right_x, info_y)
|
|
pdf.cell(col_w, 4, firmen_name, ln=True, align='R')
|
|
pdf.set_font("Arial", "", 8)
|
|
if firmen_adresse:
|
|
pdf.set_x(right_x)
|
|
pdf.cell(col_w, 3.5, firmen_adresse, ln=True, align='R')
|
|
contact_parts = []
|
|
if telefon:
|
|
contact_parts.append(f"Tel: {telefon}")
|
|
if email:
|
|
contact_parts.append(f"Mail: {email}")
|
|
if contact_parts:
|
|
pdf.set_x(right_x)
|
|
pdf.cell(col_w, 3.5, " | ".join(contact_parts), ln=True, align='R')
|
|
right_bottom = pdf.get_y()
|
|
|
|
# --- Rechnungsdaten & Empfaenger (links, unterhalb der rechten Spalte) ---
|
|
content_y = max(right_bottom + 5, 50)
|
|
pdf.set_xy(left, content_y)
|
|
pdf.set_font("Arial", "", 10)
|
|
if rechnungsnummer:
|
|
pdf.cell(80, 5, f"Rechnungsnr.: {rechnungsnummer}", ln=True)
|
|
pdf.set_x(left)
|
|
pdf.cell(80, 5, f"Datum: {datum}", ln=True)
|
|
|
|
pdf.set_xy(left, pdf.get_y() + 8)
|
|
pdf.set_font("Arial", "B", 10)
|
|
pdf.cell(80, 5, "Rechnungsempfanger:", ln=True)
|
|
pdf.set_font("Arial", "", 10)
|
|
pdf.set_x(left)
|
|
pdf.cell(80, 5, kunde, ln=True)
|
|
if adresse:
|
|
pdf.set_x(left)
|
|
pdf.set_font("Arial", "", 9)
|
|
for line in adresse.split('\n'):
|
|
pdf.set_x(left)
|
|
pdf.cell(80, 4, line.strip(), ln=True)
|
|
|
|
# --- Trennlinie ---
|
|
pdf.set_draw_color(220, 220, 220)
|
|
pdf.line(left, pdf.get_y() + 6, 195, pdf.get_y() + 6)
|
|
pdf.set_draw_color(0, 0, 0)
|
|
|
|
# --- Positionstabelle ---
|
|
table_y = pdf.get_y() + 10
|
|
pdf.set_xy(left, table_y)
|
|
pdf.set_font("Arial", "B", 10)
|
|
pdf.cell(85, 6, "Leistungsbeschreibung")
|
|
pdf.cell(20, 6, "Menge", align='R')
|
|
pdf.cell(30, 6, "Einzelpreis", align='R')
|
|
pdf.cell(30, 6, "Gesamt", ln=True, align='R')
|
|
|
|
pdf.set_draw_color(200, 200, 200)
|
|
pdf.line(left, pdf.get_y() + 0.5, 195, pdf.get_y() + 0.5)
|
|
pdf.set_draw_color(0, 0, 0)
|
|
|
|
pdf.set_font("Arial", "", 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", ln=True, align='R')
|
|
|
|
pdf.line(left, pdf.get_y() + 0.5, 195, pdf.get_y() + 0.5)
|
|
|
|
# --- Summen (linksbundig) ---
|
|
pdf.set_x(left)
|
|
pdf.ln(8)
|
|
pdf.set_font("Arial", "", 10)
|
|
pdf.cell(180, 6, f"Nettobetrag: {gesamt_netto:.2f} EUR", ln=True)
|
|
if calc.get('mwst_ausweisen'):
|
|
pdf.cell(180, 6, f"MwSt. (19%): {gesamt_mwst:.2f} EUR", ln=True)
|
|
pdf.set_font("Arial", "B", 12)
|
|
label_total = "GESAMTBETRAG (brutto):" if calc.get('mwst_ausweisen') else "GESAMTBETRAG:"
|
|
pdf.cell(180, 8, f"{label_total} {gesamt_brutto:.2f} EUR", ln=True)
|
|
|
|
# --- Zahlungsbedingungen ---
|
|
pdf.set_font("Arial", "", 9)
|
|
pdf.ln(6)
|
|
pdf.cell(180, 4, "Zahlbar innerhalb von 14 Tagen ohne Abzug. Vielen Dank fur Ihren Auftrag!")
|
|
|
|
# --- Footer ---
|
|
pdf.set_y(-35)
|
|
pdf.set_draw_color(180, 180, 180)
|
|
pdf.line(left, pdf.get_y(), 195, pdf.get_y())
|
|
pdf.set_draw_color(0, 0, 0)
|
|
pdf.ln(3)
|
|
pdf.set_font("Arial", "", 8)
|
|
pdf.set_text_color(100, 100, 100)
|
|
footer_parts = [firmen_name] if firmen_name else []
|
|
if telefon:
|
|
footer_parts.append(f"Tel: {telefon}")
|
|
if email:
|
|
footer_parts.append(email)
|
|
if footer_parts:
|
|
pdf.set_x(left)
|
|
pdf.cell(0, 4, " | ".join(footer_parts), ln=True)
|
|
if paypal:
|
|
pdf.set_x(left)
|
|
pdf.cell(0, 4, f"PayPal: {paypal}", ln=True)
|
|
if iban and bank_name:
|
|
pdf.set_x(left)
|
|
pdf.cell(0, 4, f"{iban} | {bank_name}", ln=True)
|
|
elif iban:
|
|
pdf.set_x(left)
|
|
pdf.cell(0, 4, iban, ln=True)
|
|
pdf.set_text_color(0, 0, 0)
|
|
|
|
return send_file(
|
|
io.BytesIO(pdf.output(dest='S').encode('latin-1')),
|
|
mimetype='application/pdf',
|
|
as_attachment=True,
|
|
download_name='rechnung.pdf'
|
|
)
|
|
|
|
|
|
@app.route('/export', methods=['POST'])
|
|
def export():
|
|
"""Exportiert die Kalkulation als TXT-Datei."""
|
|
data = request.get_json()
|
|
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",
|
|
]
|
|
if calc.get('mwst_ausweisen'):
|
|
lines.append(f"MwSt. (19%): {calc['mwst']:.2f} EUR")
|
|
lines.append(f"GESAMTPREIS: {calc['gesamt_brutto']:.2f} EUR")
|
|
text = '\n'.join(lines)
|
|
|
|
return send_file(
|
|
io.BytesIO(text.encode('utf-8')),
|
|
mimetype='text/plain',
|
|
as_attachment=True,
|
|
download_name='kalkulation.txt'
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run(host='0.0.0.0', port=5001, debug=True)
|