feat: add email notifications, order references, stock badges, and sale flags

- Add customer email field and PayPal payment reminder emails with order reference (HEL-YEAR-ID format)
- Display stock availability with color-coded badges (available/low/unavailable)
- Add sale/clearance flag with animated red badge overlay
- Implement automatic fallback placeholder for missing/broken product images
- Add email column to order management view

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 13:56:16 +01:00
parent 99ecea1de9
commit 832aaf2b05
7 changed files with 417 additions and 55 deletions

View File

@@ -166,6 +166,7 @@ def init_db() -> None:
db.commit()
ensure_price_column(db)
ensure_image_column(db)
ensure_sale_column(db)
ensure_orders_columns(db)
ensure_payment_columns(db)
ensure_indexes(db)
@@ -190,6 +191,16 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
db.commit()
def ensure_sale_column(db: sqlite3.Connection) -> None:
"""Fügt sale-Spalte nachträglich hinzu."""
cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "sale" for c in cols):
return
db.execute("ALTER TABLE items ADD COLUMN sale INTEGER NOT NULL DEFAULT 0")
db.commit()
logger.info("Sale-Spalte für items erstellt")
def ensure_orders_columns(db: sqlite3.Connection) -> None:
"""Sorgt für alle nachträglich eingeführten OrdersSpalten."""
cols = db.execute("PRAGMA table_info(orders)").fetchall()
@@ -206,6 +217,8 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
db.execute("ALTER TABLE orders ADD COLUMN canceled_by TEXT")
if "canceled_at" not in names:
db.execute("ALTER TABLE orders ADD COLUMN canceled_at TEXT")
if "email" not in names:
db.execute("ALTER TABLE orders ADD COLUMN email TEXT")
db.commit()
@@ -393,6 +406,7 @@ def index():
q = (request.args.get("q") or "").strip()
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
direction = (request.args.get("dir") or "desc").strip().lower()
current_year = datetime.now().year
allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"}
if sort not in allowed:
@@ -400,7 +414,7 @@ def index():
if direction not in {"asc", "desc"}:
direction = "desc"
params: list[Any] = []
params: list[Any] = [str(current_year)]
where = ""
if q:
where = "WHERE artikel LIKE ? OR groesse LIKE ?"
@@ -408,9 +422,20 @@ def index():
params.extend([like, like])
sql = f"""
SELECT *
SELECT
items.*,
COALESCE(SUM(CASE
WHEN orders.done = 1
AND orders.canceled = 0
AND orders.completed_at IS NOT NULL
AND substr(orders.completed_at, 1, 4) = ?
THEN orders.menge
ELSE 0
END), 0) AS verkaeufe_aktuell
FROM items
LEFT JOIN orders ON items.artikel = orders.artikel AND items.groesse = orders.groesse
{where}
GROUP BY items.id
ORDER BY {sort} {direction}, artikel ASC, groesse ASC
"""
rows = get_db().execute(sql, params).fetchall()
@@ -433,6 +458,7 @@ def index():
total=total,
total_bestand=total_bestand,
open_orders=open_orders,
current_year=current_year,
)
@@ -448,6 +474,7 @@ def new_item():
uploaded = save_upload(request.files.get("bild_file"))
if uploaded:
bild_url = uploaded
sale = 1 if request.form.get("sale") else 0
soll = int(request.form.get("soll") or 0)
gezaehlt = int(request.form.get("gezaehlt") or 0)
verkaeufe = int(request.form.get("verkaeufe") or 0)
@@ -456,12 +483,12 @@ def new_item():
db = get_db()
db.execute(
"""
INSERT INTO items (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO items (artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
(artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
)
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
db.execute("UPDATE items SET preis = ?, bild_url = ?, sale = ? WHERE artikel = ?", (preis, bild_url, sale, artikel))
db.commit()
return redirect(url_for("bp.index"))
@@ -485,6 +512,7 @@ def edit_item(item_id: int):
uploaded = save_upload(request.files.get("bild_file"))
if uploaded:
bild_url = uploaded
sale = 1 if request.form.get("sale") else 0
soll = int(request.form.get("soll") or 0)
gezaehlt = int(request.form.get("gezaehlt") or 0)
verkaeufe = int(request.form.get("verkaeufe") or 0)
@@ -492,12 +520,12 @@ def edit_item(item_id: int):
db.execute(
"""
UPDATE items
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, sale = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
WHERE id = ?
""",
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), item_id),
(artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, now_iso(), item_id),
)
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
db.execute("UPDATE items SET preis = ?, bild_url = ?, sale = ? WHERE artikel = ?", (preis, bild_url, sale, artikel))
db.commit()
return redirect(url_for("bp.index"))
@@ -677,7 +705,7 @@ def build_bestand() -> list[dict]:
"""Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht."""
rows = get_db().execute(
"""
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
SELECT artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe
FROM items
ORDER BY artikel, groesse
"""
@@ -690,12 +718,15 @@ def build_bestand() -> list[dict]:
continue
item = data.setdefault(
artikel,
{"artikel": artikel, "preis": 0, "bild_url": "", "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
{"artikel": artikel, "preis": 0, "bild_url": "", "sale": 0, "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
)
if not item["preis"]:
item["preis"] = float(r["preis"] or 0)
if not item["bild_url"]:
item["bild_url"] = (r["bild_url"] or "").strip()
# Sale-Status (1 wenn mindestens eine Größe sale=1 hat)
if r["sale"]:
item["sale"] = 1
soll = int(r["soll"] or 0)
gezaehlt = int(r["gezaehlt"] or 0)
verkaeufe = int(r["verkaeufe"] or 0)
@@ -743,7 +774,7 @@ def order():
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json(silent=True) or request.form
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
required = ["name", "handy", "email", "mannschaft", "artikel", "groesse", "menge"]
if any(not (data.get(k) or "").strip() for k in required):
return jsonify({"error": "Pflichtfelder fehlen."}), 400
if int(data.get("menge") or 0) <= 0:
@@ -754,14 +785,15 @@ def order():
payment_method = "bar"
db = get_db()
db.execute(
cursor = db.execute(
"""
INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
INSERT INTO orders (name, handy, email, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
""",
(
data.get("name"),
data.get("handy"),
data.get("email"),
data.get("mannschaft"),
data.get("artikel"),
data.get("groesse"),
@@ -771,8 +803,9 @@ def order():
payment_method,
),
)
order_id = cursor.lastrowid
db.commit()
logger.info(f"Neue Bestellung: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
logger.info(f"Neue Bestellung #{order_id}: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
smtp_host = os.environ.get("SMTP_HOST")
@@ -821,6 +854,69 @@ def order():
threading.Thread(target=_send, daemon=True).start()
# PayPal payment reminder to customer
if payment_method == "paypal":
customer_email = data.get("email", "").strip()
if customer_email and smtp_host and smtp_user and smtp_pass:
paypal_account = os.environ.get("PAYPAL_ACCOUNT", "")
customer_msg = EmailMessage()
customer_msg["Subject"] = "Zahlungshinweis für deine Hellas-Bestellung"
customer_msg["From"] = smtp_from
customer_msg["To"] = customer_email
# Calculate total price (need to get item price from DB)
item = db.execute(
"SELECT preis FROM items WHERE artikel = ? AND groesse = ? LIMIT 1",
(data.get("artikel"), data.get("groesse"))
).fetchone()
preis = float(item["preis"]) if item else 0
menge = int(data.get("menge") or 0)
total = preis * menge
# Referenznummer im Format HEL-2026-123
year = datetime.now().year
ref_number = f"HEL-{year}-{order_id}"
customer_body = (
f"Hallo {data.get('name')},\n\n"
f"vielen Dank für deine Bestellung bei Hellas 1899!\n\n"
f"Bestelldetails:\n"
f"• Bestellnummer: {ref_number}\n"
f"• Artikel: {data.get('artikel')}\n"
f"• Größe: {data.get('groesse')}\n"
f"• Menge: {menge}\n"
f"• Preis: {total:.2f}\n\n"
f"⚠️ WICHTIG - Zahlungshinweis:\n"
f"Du hast PayPal als Zahlungsart gewählt. Bitte überweise den Betrag an:\n\n"
f"PayPal-Konto: {paypal_account}\n"
f"Betrag: {total:.2f}\n"
f"Verwendungszweck: {ref_number}\n\n"
f"WICHTIG: Bitte wähle unbedingt die Option \"Familie & Freunde\", damit keine zusätzlichen Gebühren anfallen!\n"
f"Bitte gib die Bestellnummer {ref_number} als Verwendungszweck an, damit wir deine Zahlung zuordnen können.\n\n"
f"Sobald der Betrag eingegangen ist, wird deine Bestellung bearbeitet und du wirst benachrichtigt, wenn sie zur Abholung bereit ist.\n\n"
f"Viele Grüße\n"
f"Dein Hellas 1899 Team\n"
)
customer_msg.set_content(customer_body)
def _send_customer():
"""Sendet Zahlungs-Erinnerung an Kunden asynchron."""
try:
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(customer_msg, to_addrs=[customer_email])
logger.info(f"PayPal-Zahlungshinweis gesendet an {customer_email}")
except smtplib.SMTPAuthenticationError as e:
logger.error(f"SMTP-Authentifizierung fehlgeschlagen (Kunden-Email): {e}")
except smtplib.SMTPException as e:
logger.error(f"SMTP-Fehler beim Kunden-Email-Versand: {e}")
except Exception as e:
logger.error(f"Unerwarteter Fehler beim Kunden-Email-Versand: {e}", exc_info=True)
threading.Thread(target=_send_customer, daemon=True).start()
return jsonify({"ok": True})
@@ -831,7 +927,7 @@ def orders():
"""Bestellliste in der Verwaltung."""
rows = get_db().execute(
"""
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status, paid_at, paid_by
SELECT id, name, handy, email, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status, paid_at, paid_by
FROM orders
ORDER BY id DESC
LIMIT 500
@@ -874,11 +970,12 @@ def complete_order(order_id: int):
"""
UPDATE items
SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END,
soll = CASE WHEN soll - ? < 0 THEN 0 ELSE soll - ? END,
verkaeufe = verkaeufe + ?,
updated_at = ?
WHERE artikel = ? AND groesse = ?
""",
(menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
(menge, menge, menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
)
db.execute(
"""
@@ -930,6 +1027,80 @@ def cancel_order(order_id: int):
return redirect(url_for("bp.orders"))
@bp.route("/abrechnung")
@login_required
def abrechnung():
"""Abrechnungs-Ansicht für das aktuelle Jahr."""
db = get_db()
current_year = datetime.now().year
year = request.args.get("year", str(current_year))
# Alle erledigten Bestellungen im gewählten Jahr oder vor 2026
if year == "ALT":
orders = db.execute(
"""
SELECT o.*, i.preis
FROM orders o
LEFT JOIN items i ON o.artikel = i.artikel AND o.groesse = i.groesse
WHERE o.done = 1 AND o.canceled = 0
AND o.completed_at IS NOT NULL
AND substr(o.completed_at, 1, 4) < '2026'
ORDER BY o.completed_at DESC
""",
).fetchall()
else:
orders = db.execute(
"""
SELECT o.*, i.preis
FROM orders o
LEFT JOIN items i ON o.artikel = i.artikel AND o.groesse = i.groesse
WHERE o.done = 1 AND o.canceled = 0
AND o.completed_at IS NOT NULL
AND substr(o.completed_at, 1, 4) = ?
ORDER BY o.completed_at DESC
""",
(year,),
).fetchall()
# Gesamtumsatz berechnen
total_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in orders)
total_items = sum(o["menge"] or 0 for o in orders)
# Nach Zahlungsart gruppieren
paypal_orders = [o for o in orders if o["payment_method"] == "paypal"]
bar_orders = [o for o in orders if o["payment_method"] == "bar"]
paypal_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in paypal_orders)
bar_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in bar_orders)
# Nach Artikel gruppieren
artikel_stats = {}
for o in orders:
key = o["artikel"]
if key not in artikel_stats:
artikel_stats[key] = {"menge": 0, "umsatz": 0}
artikel_stats[key]["menge"] += o["menge"] or 0
artikel_stats[key]["umsatz"] += (o["preis"] or 0) * (o["menge"] or 0)
artikel_list = [
{"artikel": k, "menge": v["menge"], "umsatz": v["umsatz"]}
for k, v in sorted(artikel_stats.items(), key=lambda x: x[1]["umsatz"], reverse=True)
]
return render_template(
"abrechnung.html",
year=year,
current_year=current_year,
orders=orders,
total_revenue=total_revenue,
total_items=total_items,
paypal_count=len(paypal_orders),
bar_count=len(bar_orders),
paypal_revenue=paypal_revenue,
bar_revenue=bar_revenue,
artikel_list=artikel_list,
)
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
@login_required
def reset_user_password(user_id: int):