Vermietungs-Tool
A local web app for a small room-rental business in Austria: guest register, room ledger, occupancy plan, invoices, the monthly tourist-tax report and the quarterly VAT list. Application code only; it ships no data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
45424e6fd6
47 files changed
+6701
No files matched your search
+737
@@ -0,0 +1,737 @@
|
||||
"""Flask app: the web UI. Refuses to start while a migration is pending. All data access goes through
|
||||
repo.py, so scripts get the same operations as the pages.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import mimetypes
|
||||
import secrets
|
||||
import struct
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (Flask, render_template, request, redirect, url_for, g, flash, abort, send_file)
|
||||
|
||||
from app import compute, db, i18n, money, pdf, plan, repo, quality, tvb, vat
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
INVOICE_ASSETS = APP_DIR / "static" / "invoice"
|
||||
|
||||
|
||||
def get_db():
|
||||
if "conn" not in g:
|
||||
g.conn = db.connect(actor="web")
|
||||
return g.conn
|
||||
|
||||
|
||||
_page_cache = {}
|
||||
|
||||
|
||||
def assets_fingerprint():
|
||||
"""Newest mtime of the grid's own code — static files AND templates. It belongs in the fingerprint that
|
||||
keys the FROZEN column layouts in the browser's localStorage: without it a change is invisible after a
|
||||
reload, because the browser re-applies the widths it measured against the old code. The templates count
|
||||
too: a template change can widen a column without touching a single static file."""
|
||||
files = [APP_DIR / "static" / f for f in ("grids.js", "style.css")]
|
||||
files += sorted((APP_DIR / "templates").glob("*.html"))
|
||||
return str(max(int(f.stat().st_mtime) for f in files))
|
||||
|
||||
|
||||
def _ledger_year(row):
|
||||
"""Which year does a Zimmerbelegung row belong to? That of the first date it actually has —
|
||||
`repo.ledger_rows` says which."""
|
||||
return (row["first_date"] or "")[:4] or None
|
||||
|
||||
|
||||
def page_fingerprint():
|
||||
"""What a rendered list page depends on: the data AND the grid code. Keys both the server-side page
|
||||
cache and the frozen column layouts in the browser — a cached page carrying the old fingerprint would
|
||||
keep the stale layout alive just as effectively as the layout itself."""
|
||||
return db_fingerprint() + "-" + assets_fingerprint()
|
||||
|
||||
|
||||
def db_fingerprint():
|
||||
"""The schema and its write counter (`data_version`, counted up by every statement that writes a data
|
||||
table — this app or any script; migration 001). Keys the server-side page cache and the
|
||||
browser's stored grid layouts. The schema belongs in it: the tests run each in a schema of their own."""
|
||||
return f"{db.SCHEMA}.{db.row(get_db(), 'SELECT version FROM data_version')[0]}"
|
||||
|
||||
|
||||
def cached_page(view):
|
||||
"""Serve the big read-only list pages from memory while the database is unchanged.
|
||||
Pages carrying flash messages are rendered fresh and never stored."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(view)
|
||||
def wrapped(**kwargs):
|
||||
from flask import session
|
||||
if "_flashes" in session:
|
||||
return view(**kwargs)
|
||||
key = (request.full_path, page_fingerprint())
|
||||
if _page_cache.get(view.__name__, (None,))[0] != key:
|
||||
_page_cache[view.__name__] = (key, view(**kwargs))
|
||||
return _page_cache[view.__name__][1]
|
||||
return wrapped
|
||||
|
||||
|
||||
def asset_uri(filename):
|
||||
"""Inline an invoice image as a data URI so it renders in both browser-print and the headless PDF."""
|
||||
if not filename:
|
||||
return ""
|
||||
path = INVOICE_ASSETS / filename
|
||||
if not path.exists():
|
||||
return ""
|
||||
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
||||
return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode()
|
||||
|
||||
|
||||
ART_BOX_W, ART_BOX_H, ART_GAP = 64, 80, 4 # masthead art box (mm) — keep in sync with .masthead .art CSS
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _image_size(path_str):
|
||||
"""Pixel (w, h) of a PNG or JPEG read from the file header — avoids an imaging dependency."""
|
||||
data = Path(path_str).read_bytes()
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
w, h = struct.unpack(">II", data[16:24])
|
||||
return w, h
|
||||
if data[:2] == b"\xff\xd8":
|
||||
i = 2
|
||||
while i + 9 < len(data):
|
||||
if data[i] != 0xFF:
|
||||
i += 1
|
||||
elif data[i + 1] == 0x01 or 0xD0 <= data[i + 1] <= 0xD9: # marker without a length field
|
||||
i += 2
|
||||
elif 0xC0 <= data[i + 1] <= 0xCF and data[i + 1] not in (0xC4, 0xC8, 0xCC): # SOFn
|
||||
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
||||
return w, h
|
||||
else:
|
||||
i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0]
|
||||
raise ValueError(f"cannot read image size: {path_str}")
|
||||
|
||||
|
||||
def masthead_art(unit):
|
||||
"""Explicit print sizes for the branding images. All images of a unit share ONE width, solved so the
|
||||
stack exactly fills the art box height (capped at the box width) — a wide logo can never render bigger
|
||||
than the house photo, and a lone image grows to fill the whole box."""
|
||||
imgs = [{"src": name, "cls": cls} for name, cls in ((unit.get("logo"), "logo"), (unit.get("photo"), "photo"))
|
||||
if name and (INVOICE_ASSETS / name).exists()]
|
||||
for a in imgs:
|
||||
w_px, h_px = _image_size(str(INVOICE_ASSETS / a["src"]))
|
||||
a["ratio"] = h_px / w_px
|
||||
if not imgs:
|
||||
return []
|
||||
usable_h = ART_BOX_H - ART_GAP * (len(imgs) - 1)
|
||||
width = min(ART_BOX_W, usable_h / sum(a["ratio"] for a in imgs))
|
||||
for a in imgs:
|
||||
a["w"] = round(width, 2)
|
||||
a["h"] = round(width * a.pop("ratio"), 2)
|
||||
return imgs
|
||||
|
||||
|
||||
def _tourism_rate(on_date=None):
|
||||
"""The Aufenthaltsabgabe per person and night in force on that date — handed to the ledger form so the
|
||||
browser can propose the amount without a second copy of the rate table."""
|
||||
import datetime
|
||||
return compute.tourism_rate(on_date or datetime.date.today().isoformat())
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.secret_key = secrets.token_hex(32) # the session only carries flash messages across a redirect
|
||||
|
||||
# Static assets: version the URL by file mtime and cache hard. Without this the browser's heuristic
|
||||
# cache kept serving OLD grids.js/style.css after updates (no revalidation for hours/days).
|
||||
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 60 * 60 * 24 * 365
|
||||
|
||||
@app.url_defaults
|
||||
def bust_static_cache(endpoint, values):
|
||||
if endpoint == "static" and "filename" in values:
|
||||
f = APP_DIR / "static" / values["filename"]
|
||||
if f.exists():
|
||||
values["v"] = int(f.stat().st_mtime)
|
||||
|
||||
conn = db.connect()
|
||||
pending = db.pending_migrations(conn)
|
||||
conn.close()
|
||||
if pending: # the app's role may not change the schema — the owner does, before the app starts
|
||||
raise RuntimeError(f"database schema is behind the code (migrations {pending}) — "
|
||||
f"run first: python -m app.db migrate")
|
||||
|
||||
# the grid pages render thousands of rows — don't ship template indentation as page weight
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
app.context_processor(lambda: {"db_fp": page_fingerprint()})
|
||||
app.jinja_env.globals["t"] = i18n.t
|
||||
app.jinja_env.globals["asset_uri"] = asset_uri
|
||||
app.jinja_env.globals["masthead_art"] = masthead_art
|
||||
app.jinja_env.filters["euro"] = money.euro
|
||||
app.jinja_env.filters["num"] = money.de_number
|
||||
app.jinja_env.filters["date"] = money.de_date
|
||||
app.jinja_env.filters["datewd"] = money.de_date_wd # with the weekday, for the two lists
|
||||
|
||||
@app.after_request
|
||||
def never_cache_pages(resp):
|
||||
# dynamic pages must always re-render — without this, the browser's back button (bfcache /
|
||||
# heuristic caching) shows a STALE copy after an edit. Versioned static assets keep their year.
|
||||
if request.endpoint != "static":
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exc):
|
||||
conn = g.pop("conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return render_template("home.html", stats=repo.stats(get_db()))
|
||||
|
||||
# --- guest register ---
|
||||
@app.route("/register")
|
||||
@cached_page
|
||||
def register():
|
||||
search = request.args.get("q", "").strip()
|
||||
return render_template("register.html", rows=repo.register_rows(get_db(), search), q=search)
|
||||
|
||||
@app.route("/register/new", methods=["GET", "POST"])
|
||||
@app.route("/register/<int:stay_id>/edit", methods=["GET", "POST"])
|
||||
def stay_form(stay_id=None):
|
||||
conn = get_db()
|
||||
stay = repo.get_stay(conn, stay_id) if stay_id else None
|
||||
# a room change: the new sheet continues the old one — same person, same number
|
||||
from_id = None if stay_id else request.args.get("from", type=int)
|
||||
moved_from = repo.get_stay(conn, from_id) if from_id else None
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
if not f.get("last_name", "").strip():
|
||||
flash(i18n.t("err_required", field=i18n.t("col_name")))
|
||||
elif f.get("arrival") and f.get("departure") and f["departure"] < f["arrival"]:
|
||||
flash(i18n.t("err_departure_before_arrival")) # reject at the door, not on Datenprüfung
|
||||
else:
|
||||
last = f["last_name"].strip()
|
||||
first = f.get("first_name", "").strip() or None
|
||||
gfields = dict(birth_date=f.get("birth_date") or None, nationality=f.get("nationality") or None,
|
||||
doc_type=f.get("doc_type") or None, doc_number=f.get("doc_number") or None,
|
||||
profession=f.get("profession") or None, home_street=f.get("home_street") or None,
|
||||
home_postcode=f.get("home_postcode") or None, home_city=f.get("home_city") or None,
|
||||
home_country=f.get("home_country") or None, phone=f.get("phone") or None)
|
||||
person = stay or moved_from # edit, or the same person in another room
|
||||
if person and person.get("guest_id"): # update this exact person
|
||||
repo.update_guest(conn, person["guest_id"], last_name=last, first_name=first, **gfields)
|
||||
guest_id = person["guest_id"]
|
||||
else: # new: reuse only on exact person match
|
||||
guest_id = repo.find_or_create_guest(conn, last, first, **gfields)
|
||||
room_id = int(f["room_id"]) if f.get("room_id") else None
|
||||
saved = repo.save_stay(conn, stay_id, guest_id, room_id,
|
||||
int(f["unit_id"]) if f.get("unit_id") else None,
|
||||
repo.find_or_create_client(conn, f.get("employer")),
|
||||
f.get("arrival") or None, f.get("departure") or None,
|
||||
f.get("sheet_number") or None,
|
||||
int(f["hauptwohnsitz"]) if f.get("hauptwohnsitz") in ("0", "1") else None,
|
||||
int(f["tourism_liable"]) if f.get("tourism_liable") in ("0", "1") else None,
|
||||
f.get("notes") or None)
|
||||
if f.get("action") == "room_change":
|
||||
if not f.get("departure"): # the departure IS the day of the change
|
||||
flash(i18n.t("err_room_change_needs_date"))
|
||||
return redirect(url_for("stay_form", stay_id=saved))
|
||||
return redirect(url_for("stay_form", **{"from": saved}))
|
||||
if moved_from and repo.carry_ledger_to_room(conn, f.get("sheet_number") or None,
|
||||
moved_from["room_id"], room_id, f.get("arrival"),
|
||||
{"last_name": last, "first_name": first}):
|
||||
flash(i18n.t("msg_room_changed", date=money.de_date(f["arrival"]),
|
||||
room=_room_number(conn, room_id)))
|
||||
return redirect(url_for("register"))
|
||||
if moved_from: # the old sheet, as the new one starts
|
||||
stay = dict(moved_from, id=None, room_id=None, arrival=moved_from["departure"], departure=None,
|
||||
notes=i18n.t("note_room_change", room=_room_number(conn, moved_from["room_id"])))
|
||||
return render_template("stay_form.html", stay=stay, room_change=bool(moved_from), rooms=repo.rooms(conn),
|
||||
units=repo.units(conn), suggest=repo.field_suggestions(conn))
|
||||
|
||||
@app.route("/register/<int:stay_id>/delete", methods=["POST"])
|
||||
def stay_delete(stay_id):
|
||||
try:
|
||||
repo.delete_stay(get_db(), stay_id)
|
||||
except ValueError:
|
||||
flash(i18n.t("err_delete_blocked"))
|
||||
return redirect(url_for("register"))
|
||||
|
||||
# --- room ledger ---
|
||||
@app.route("/ledger")
|
||||
@cached_page
|
||||
def ledger():
|
||||
conn = get_db()
|
||||
room = request.args.get("room", "").strip()
|
||||
search = request.args.get("q", "").strip()
|
||||
rows = repo.ledger_rows(conn, room or None, search)
|
||||
years = sorted({y for y in (_ledger_year(r) for r in rows) if y}, reverse=True)
|
||||
return render_template("ledger.html", rows=rows, years=years,
|
||||
rooms=repo.rooms(conn), room=room, q=search)
|
||||
|
||||
@app.route("/ledger/new", methods=["GET", "POST"])
|
||||
def ledger_new():
|
||||
conn = get_db()
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
room = f.get("room_id", "").strip()
|
||||
repo.create_ledger_row(conn, int(room) if room.isdigit() else None,
|
||||
f.get("occupant", "").strip() or None,
|
||||
f.get("period_from") or None, f.get("period_to") or None,
|
||||
parse_number(f.get("persons")), parse_number(f.get("quantity")),
|
||||
f.get("unit_type") or None, parse_number(f.get("gross_per_unit")),
|
||||
parse_number(f.get("betrag")), f.get("paid_status") or None,
|
||||
f.get("paid_date") or None, f.get("art") or None,
|
||||
f.get("payer", "").strip() or None, f.get("rnr", "").strip() or None,
|
||||
f.get("gb_ref", "").strip() or None, f.get("notes", "").strip() or None,
|
||||
tourism_tax=parse_number(f.get("tourism_tax")),
|
||||
payments=_payments(f) or [])
|
||||
return redirect(url_for("ledger"))
|
||||
return render_template("ledger_form.html", ch={}, rooms=repo.rooms(conn), is_new=True,
|
||||
tourism_rate=_tourism_rate(None))
|
||||
|
||||
@app.route("/ledger/<int:charge_id>/edit", methods=["GET", "POST"])
|
||||
def ledger_edit(charge_id):
|
||||
conn = get_db()
|
||||
ch = repo.get_charge(conn, charge_id)
|
||||
if not ch:
|
||||
abort(404)
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
repo.update_charge(conn, charge_id, f.get("occupant", "").strip() or None,
|
||||
f.get("period_from") or None, f.get("period_to") or None,
|
||||
parse_number(f.get("persons")), parse_number(f.get("quantity")),
|
||||
f.get("unit_type") or None, parse_number(f.get("gross_per_unit")),
|
||||
parse_number(f.get("betrag")), f.get("paid_status") or None,
|
||||
f.get("paid_date") or None, f.get("art") or None,
|
||||
f.get("payer", "").strip() or None, f.get("rnr", "").strip() or None,
|
||||
f.get("gb_ref", "").strip() or None, f.get("notes", "").strip() or None,
|
||||
tourism_tax=parse_number(f.get("tourism_tax")),
|
||||
payments=_payments(f))
|
||||
if f.get("action") == "continue":
|
||||
# month-end billing: bill up to this date, then carry the occupancy on from it
|
||||
if not f.get("period_to"):
|
||||
flash(i18n.t("err_continue_needs_date"))
|
||||
return redirect(url_for("ledger_edit", charge_id=charge_id))
|
||||
repo.continue_ledger_row(conn, charge_id)
|
||||
flash(i18n.t("msg_continued", date=money.de_date(f["period_to"])))
|
||||
return redirect(url_for("ledger"))
|
||||
return render_template("ledger_form.html", ch=ch, tourism_rate=_tourism_rate(ch.get("period_from")))
|
||||
|
||||
@app.route("/ledger/<int:charge_id>/delete", methods=["POST"])
|
||||
def ledger_delete(charge_id):
|
||||
repo.delete_charge(get_db(), charge_id)
|
||||
return redirect(url_for("ledger"))
|
||||
|
||||
# --- Belegungsplan (occupancy timeline) ---
|
||||
@app.route("/plan")
|
||||
def plan_page():
|
||||
# not @cached_page: the view depends on today's date (today marker, occupancy pills),
|
||||
# and the cache key only sees URL + db fingerprint — it would survive midnight.
|
||||
import datetime
|
||||
weeks = 8 if request.args.get("weeks") == "8" else 4
|
||||
try:
|
||||
start = datetime.date.fromisoformat(request.args.get("start", ""))
|
||||
except ValueError:
|
||||
start = plan.default_start()
|
||||
return render_template("plan.html", p=plan.build(get_db(), start, weeks * 7), weeks=weeks)
|
||||
|
||||
# --- invoices ---
|
||||
@app.route("/invoices")
|
||||
@cached_page
|
||||
def invoices():
|
||||
q = request.args.get("q", "").strip()
|
||||
return render_template("invoices.html", rows=repo.invoices_list(get_db(), q), q=q)
|
||||
|
||||
def _rows_follow(conn, invoice_id):
|
||||
"""The Zimmerbelegung takes over what the saved invoice bills — and the user is told it did."""
|
||||
fixed, new = repo.sync_rows_to_invoice(conn, invoice_id)
|
||||
if fixed or new:
|
||||
flash(i18n.t("msg_rows_follow_invoice", fixed=len(fixed), new=len(new)))
|
||||
|
||||
@app.route("/invoices/new", methods=["GET", "POST"])
|
||||
def invoice_new():
|
||||
conn = get_db()
|
||||
units = repo.units(conn)
|
||||
f = request.form
|
||||
lines = []
|
||||
order_warning = False
|
||||
client_choices = None
|
||||
if request.method == "POST":
|
||||
lines, incomplete = _parse_lines(f)
|
||||
unit_id = int(f["unit_id"]) if f.get("unit_id") else None
|
||||
number = f.get("number", "").strip()
|
||||
if incomplete:
|
||||
flash(i18n.t("err_number", field=", ".join(incomplete)))
|
||||
elif not unit_id or not f.get("client", "").strip() or not lines:
|
||||
flash(i18n.t("err_required", field="Einheit, Kunde, mind. 1 Zeile"))
|
||||
elif number and repo.invoice_number_taken(conn, unit_id, number):
|
||||
flash(i18n.t("err_number_taken", number=number))
|
||||
elif _order_warning(conn, f, unit_id, number):
|
||||
order_warning = True
|
||||
elif (client_choices := _recipient_warning(conn, f)) is None:
|
||||
links = [int(x) for x in f.get("link_charges", "").split(",") if x.strip().isdecimal()]
|
||||
inv_id = repo.create_invoice(
|
||||
conn, unit_id, repo.find_or_create_client(conn, f["client"]),
|
||||
number, f.get("date") or None, lines, note=f.get("note"),
|
||||
paid_date=f.get("paid_date") or None, payment_method=f.get("payment_method") or None,
|
||||
link_charge_ids=links)
|
||||
_rows_follow(conn, inv_id)
|
||||
return redirect(url_for("invoice_view", invoice_id=inv_id))
|
||||
lines = _raw_lines(f) # re-render exactly what was typed, incl. the rejected rows
|
||||
elif request.args.get("stays") or request.args.get("charges"):
|
||||
# generate a pre-filled invoice from selected Gästeblätter rows or Zimmerbelegung rows
|
||||
if request.args.get("charges"):
|
||||
ids = [int(x) for x in request.args["charges"].split(",") if x.strip().isdecimal()]
|
||||
pre = repo.lines_from_charges(conn, ids)
|
||||
else:
|
||||
ids = [int(x) for x in request.args["stays"].split(",") if x.strip().isdecimal()]
|
||||
pre = repo.lines_from_stays(conn, ids)
|
||||
lines = pre["lines"]
|
||||
f = {"unit_id": pre["unit_id"], "client": pre["client"] or "",
|
||||
# remember the ledger rows so saving writes the invoice number back into them
|
||||
"link_charges": ",".join(map(str, ids)) if request.args.get("charges") else ""}
|
||||
if pre["client"]: # a payer without an address asks at once, not only on saving
|
||||
client_choices = repo.recipient_choices(conn, pre["client"])
|
||||
import datetime
|
||||
suggestions = {u["id"]: repo.next_invoice_number(conn, u["id"]) for u in units}
|
||||
return render_template("invoice_form.html", units=units, suggestions=suggestions, form=f, lines=lines,
|
||||
suggest=repo.field_suggestions(conn), order_warning=order_warning,
|
||||
client_choices=client_choices, today=datetime.date.today().isoformat())
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/edit", methods=["GET", "POST"])
|
||||
def invoice_edit(invoice_id):
|
||||
conn = get_db()
|
||||
inv = repo.get_invoice(conn, invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
units = repo.units(conn)
|
||||
order_warning = False
|
||||
client_choices = None
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
lines, incomplete = _parse_lines(f)
|
||||
unit_id = int(f["unit_id"]) if f.get("unit_id") else None
|
||||
number = f.get("number", "").strip()
|
||||
if incomplete:
|
||||
flash(i18n.t("err_number", field=", ".join(incomplete)))
|
||||
elif not unit_id or not f.get("client", "").strip() or not lines:
|
||||
flash(i18n.t("err_required", field="Einheit, Kunde, mind. 1 Zeile"))
|
||||
elif number and repo.invoice_number_taken(conn, unit_id, number, exclude_id=invoice_id):
|
||||
flash(i18n.t("err_number_taken", number=number))
|
||||
elif _order_warning(conn, f, unit_id, number, invoice_id):
|
||||
order_warning = True
|
||||
elif (client_choices := _recipient_warning(conn, f)) is None:
|
||||
repo.update_invoice(conn, invoice_id, unit_id,
|
||||
repo.find_or_create_client(conn, f["client"]), number,
|
||||
f.get("date") or None, lines, note=f.get("note"),
|
||||
paid_date=f.get("paid_date") or None, payment_method=f.get("payment_method") or None)
|
||||
_rows_follow(conn, invoice_id)
|
||||
return redirect(url_for("invoice_view", invoice_id=invoice_id))
|
||||
return render_template("invoice_form.html", units=units, suggestions={}, form=f,
|
||||
lines=_raw_lines(f), editing=inv, suggest=repo.field_suggestions(conn),
|
||||
order_warning=order_warning, client_choices=client_choices)
|
||||
form = {"unit_id": inv["unit_id"], "client": (inv["client"] or {}).get("name", ""),
|
||||
"number": inv["number"], "date": inv["date"], "note": inv.get("notes"),
|
||||
"paid_date": inv.get("paid_date"), "payment_method": inv.get("payment_method")}
|
||||
return render_template("invoice_form.html", units=units, suggestions={}, form=form, lines=inv["lines"],
|
||||
editing=inv, suggest=repo.field_suggestions(conn),
|
||||
client_choices=repo.recipient_choices(conn, form["client"]))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/delete", methods=["POST"])
|
||||
def invoice_delete(invoice_id):
|
||||
repo.delete_invoice(get_db(), invoice_id)
|
||||
return redirect(url_for("invoices"))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>")
|
||||
def invoice_view(invoice_id):
|
||||
conn = get_db()
|
||||
inv = repo.get_invoice(conn, invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
# offer "Neu generieren" only when a linked ledger row changed since the invoice was (re)generated
|
||||
can_regen = bool(inv.get("lines_stale")) and bool(repo.linked_ledger_charges(conn, invoice_id))
|
||||
return render_template("invoice_print.html", inv=inv, can_regen=can_regen)
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/regenerate", methods=["POST"])
|
||||
def invoice_regenerate(invoice_id):
|
||||
if not repo.regenerate_invoice(get_db(), invoice_id):
|
||||
abort(404)
|
||||
return redirect(url_for("invoice_view", invoice_id=invoice_id))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/payment", methods=["GET", "POST"])
|
||||
def invoice_payment(invoice_id):
|
||||
"""Record the settlement of an already issued invoice — the ONLY writing path onto one that is not
|
||||
a full edit. Deliberate and manual by design: an issued invoice never changes as a side effect."""
|
||||
conn = get_db()
|
||||
inv = repo.get_invoice(conn, invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
if request.method == "POST":
|
||||
repo.set_invoice_payment(conn, invoice_id, request.form.get("paid_date"),
|
||||
request.form.get("payment_method"))
|
||||
return redirect(url_for("invoice_view", invoice_id=invoice_id))
|
||||
return render_template("invoice_payment.html", inv=inv,
|
||||
hint=repo.invoice_payment_hint(conn, invoice_id))
|
||||
|
||||
@app.route("/invoices/<int:invoice_id>/pdf")
|
||||
def invoice_pdf(invoice_id):
|
||||
inv = repo.get_invoice(get_db(), invoice_id)
|
||||
if not inv:
|
||||
abort(404)
|
||||
html = render_template("invoice_print.html", inv=inv, standalone=True)
|
||||
return send_file(io.BytesIO(pdf.render(html)), mimetype="application/pdf", as_attachment=True,
|
||||
download_name=repo.invoice_filename(inv))
|
||||
|
||||
# --- companies (canonical clients + their address versions) ---
|
||||
@app.route("/clients")
|
||||
@cached_page
|
||||
def clients():
|
||||
q = request.args.get("q", "").strip()
|
||||
return render_template("clients.html", rows=repo.companies_list(get_db(), q or None), q=q)
|
||||
|
||||
@app.route("/clients/<int:company_id>", methods=["GET", "POST"])
|
||||
def client_edit(company_id):
|
||||
conn = get_db()
|
||||
comp = repo.get_company(conn, company_id)
|
||||
if not comp:
|
||||
abort(404)
|
||||
if request.method == "POST":
|
||||
f = request.form
|
||||
if f.get("action") == "add_address":
|
||||
repo.add_company_address(conn, company_id, f.get("address", "").strip(),
|
||||
f.get("postcode", "").strip(), f.get("city", "").strip(),
|
||||
make_current=bool(f.get("make_current")))
|
||||
elif f.get("action") == "edit_address":
|
||||
repo.update_client_address(conn, int(f["client_id"]), f.get("name", ""),
|
||||
f.get("address", ""), f.get("postcode", ""), f.get("city", ""))
|
||||
elif f.get("action") == "make_current":
|
||||
repo.set_current_address(conn, company_id, int(f["client_id"]))
|
||||
elif f.get("action") == "delete_address":
|
||||
try:
|
||||
repo.delete_client_address(conn, int(f["client_id"]))
|
||||
except ValueError as e:
|
||||
flash(i18n.t("err_address_last" if str(e) == "last_version" else "err_address_has_refs"))
|
||||
elif f.get("action") == "merge":
|
||||
target = f.get("merge_into", "").strip()
|
||||
if target.isdigit() and int(target) != company_id:
|
||||
repo.merge_companies(conn, int(target), company_id)
|
||||
return redirect(url_for("client_edit", company_id=int(target)))
|
||||
elif not f.get("name", "").strip():
|
||||
flash(i18n.t("err_required", field=i18n.t("col_name")))
|
||||
else:
|
||||
repo.save_company(conn, company_id, f["name"].strip(), f.get("kind") or "company",
|
||||
f.get("uid", "").strip() or None, f.get("email", "").strip() or None,
|
||||
f.get("phone", "").strip() or None, f.get("notes", "").strip() or None,
|
||||
int(f["current_client_id"]) if f.get("current_client_id", "").isdigit() else None)
|
||||
return redirect(url_for("clients"))
|
||||
return redirect(url_for("client_edit", company_id=company_id))
|
||||
others = [c for c in repo.companies_list(conn) if c["id"] != company_id]
|
||||
return render_template("client_form.html", comp=comp, others=others)
|
||||
|
||||
@app.route("/clients/<int:company_id>/delete", methods=["POST"])
|
||||
def client_delete(company_id):
|
||||
try:
|
||||
repo.delete_company(get_db(), company_id)
|
||||
except ValueError:
|
||||
flash(i18n.t("err_company_has_refs"))
|
||||
return redirect(url_for("client_edit", company_id=company_id))
|
||||
return redirect(url_for("clients"))
|
||||
|
||||
# --- data-quality audit ---
|
||||
@app.route("/quality")
|
||||
@cached_page
|
||||
def quality_page():
|
||||
return render_template("quality.html", q=quality.overview(get_db()))
|
||||
|
||||
# --- monthly TVB Sammelmeldung (Aufenthaltsabgabe) ---
|
||||
@app.route("/tvb")
|
||||
def tvb_page():
|
||||
import datetime
|
||||
m = request.args.get("month", "")
|
||||
try:
|
||||
year, month = int(m[:4]), int(m[5:7])
|
||||
datetime.date(year, month, 1)
|
||||
except ValueError: # default: the month that is due now
|
||||
prev = datetime.date.today().replace(day=1) - datetime.timedelta(days=1)
|
||||
year, month = prev.year, prev.month
|
||||
return render_template("tvb.html", r=tvb.monthly_report(get_db(), year, month),
|
||||
month_value=f"{year:04d}-{month:02d}")
|
||||
|
||||
# --- quarterly Umsatzsteuer-Voranmeldung ---
|
||||
@app.route("/vat")
|
||||
def vat_page():
|
||||
import datetime
|
||||
conn = get_db()
|
||||
today = datetime.date.today()
|
||||
year, quarter = vat.previous_quarter(today) # default: the quarter that is due now
|
||||
try:
|
||||
year = int(request.args.get("year", year))
|
||||
quarter = min(max(int(request.args.get("q", quarter)), 1), 4)
|
||||
except ValueError:
|
||||
pass
|
||||
units = vat.units(conn)
|
||||
names = {u["code"]: u["name"] for u in units}
|
||||
unit = request.args.get("unit") if request.args.get("unit") in names else next(iter(names), None)
|
||||
return render_template("vat.html", r=vat.quarterly_report(conn, unit, year, quarter), units=units,
|
||||
unit=unit, unit_name=names.get(unit, unit), year=year, quarter=quarter,
|
||||
years=range(today.year, today.year - 8, -1))
|
||||
|
||||
@app.route("/quality/companies/merge", methods=["POST"])
|
||||
def quality_merge_companies():
|
||||
f = request.form
|
||||
if f.get("keep", "").isdigit() and f.get("drop", "").isdigit():
|
||||
repo.merge_companies(get_db(), int(f["keep"]), int(f["drop"]))
|
||||
return redirect(url_for("quality_page"))
|
||||
|
||||
@app.route("/quality/guests/merge", methods=["POST"])
|
||||
def quality_merge_guests():
|
||||
f = request.form
|
||||
if f.get("keep", "").isdigit() and f.get("drop", "").isdigit():
|
||||
repo.merge_guests(get_db(), int(f["keep"]), int(f["drop"]))
|
||||
return redirect(url_for("quality_page"))
|
||||
|
||||
@app.route("/quality/dismiss", methods=["POST"])
|
||||
def quality_dismiss():
|
||||
f = request.form
|
||||
if f.get("kind") and f.get("key"):
|
||||
quality.dismiss(get_db(), f["kind"], f["key"], f.get("note", "").strip() or None)
|
||||
return redirect(url_for("quality_page"))
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template("error.html", message=i18n.t("err_not_found")), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
return render_template("error.html", message=i18n.t("err_generic")), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _payments(f):
|
||||
"""The ledger form's payment lines as [(date, amount)] — None when the form carries no payment lines at
|
||||
all, which leaves the row's payments untouched. A line without an amount is the spare empty one; the
|
||||
form makes the date required as soon as an amount is typed."""
|
||||
if "pay_amount" not in f:
|
||||
return None
|
||||
lines = zip(f.getlist("pay_date"), (parse_number(a) for a in f.getlist("pay_amount")))
|
||||
return [(d, a) for d, a in lines if d and a]
|
||||
|
||||
|
||||
def _room_number(conn, room_id):
|
||||
return next((r["number"] for r in repo.rooms(conn) if r["id"] == room_id), "")
|
||||
|
||||
|
||||
def parse_number(value):
|
||||
"""Accept German decimals ('161,50', '1.234,56') and plain numbers; None if not parseable."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip().replace(" ", "")
|
||||
if not s:
|
||||
return None
|
||||
if "," in s: # German: comma = decimal, dot = thousands separator
|
||||
s = s.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _raw_lines(form):
|
||||
"""The submitted line rows verbatim, for re-rendering a form that failed validation — a rejected row
|
||||
must come back filled in, not vanish."""
|
||||
keys = ("worker", "period_from", "period_to", "quantity", "unit_type", "gross_per_unit", "tourism", "kind")
|
||||
lists = {k: form.getlist(k) for k in keys}
|
||||
n = max((len(v) for v in lists.values()), default=0)
|
||||
rows = []
|
||||
for i in range(n):
|
||||
row = {k: (lists[k][i] if i < len(lists[k]) else "") for k in keys}
|
||||
row["tourism_tax"] = row.pop("tourism")
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _order_warning(conn, f, unit_id, number, exclude_id=None):
|
||||
"""Stop the save and say which invoice is in the way when the date breaks the number order
|
||||
(`repo.invoice_out_of_order`) — unless the user has ticked that the date is right on purpose."""
|
||||
clash = repo.invoice_out_of_order(conn, unit_id, number, f.get("date") or None, exclude_id)
|
||||
if not clash or f.get("confirm_order"):
|
||||
return False
|
||||
flash(i18n.t("warn_order_smaller" if clash["smaller"] else "warn_order_larger", number=number,
|
||||
date=money.de_date(f.get("date")), other=clash["number"], other_date=money.de_date(clash["date"])))
|
||||
return True
|
||||
|
||||
|
||||
def _recipient_warning(conn, f):
|
||||
"""None when the recipient prints an address; otherwise stop the save and return the companies the
|
||||
name may mean (`repo.recipient_choices`) — unless the user has ticked that it is right without one."""
|
||||
if f.get("confirm_client"):
|
||||
return None
|
||||
name = f.get("client", "").strip()
|
||||
choices = repo.recipient_choices(conn, name)
|
||||
if choices is not None:
|
||||
flash(i18n.t("warn_client_no_address", name=name))
|
||||
return choices
|
||||
|
||||
|
||||
def _parse_lines(form):
|
||||
"""Return (valid_lines, incomplete). A row with a name but no valid rate/quantity (or vice versa) is
|
||||
reported as incomplete rather than silently dropped, so an invoice can't be undercharged unnoticed."""
|
||||
workers = form.getlist("worker")
|
||||
gross, qtys = form.getlist("gross_per_unit"), form.getlist("quantity")
|
||||
pfrom, pto, utype = form.getlist("period_from"), form.getlist("period_to"), form.getlist("unit_type")
|
||||
tourism, kinds = form.getlist("tourism"), form.getlist("kind")
|
||||
|
||||
def at(lst, i):
|
||||
return lst[i] if i < len(lst) else None
|
||||
|
||||
lines, incomplete = [], []
|
||||
for i, worker in enumerate(workers):
|
||||
worker = worker.strip()
|
||||
rate = parse_number(at(gross, i))
|
||||
qty = parse_number(at(qtys, i))
|
||||
if not worker and rate is None:
|
||||
continue # empty row
|
||||
if not worker or rate is None or not qty or qty <= 0: # a blank/zero Anz is a mistake, not a free line
|
||||
incomplete.append(worker or "Zeile {}".format(i + 1))
|
||||
continue
|
||||
lines.append({
|
||||
"worker": worker,
|
||||
"period_from": at(pfrom, i) or None,
|
||||
"period_to": at(pto, i) or None,
|
||||
"quantity": qty,
|
||||
"unit_type": at(utype, i) or "week",
|
||||
"gross_per_unit": rate,
|
||||
"tourism_tax": parse_number(at(tourism, i)) or 0,
|
||||
"kind": at(kinds, i) or "stay",
|
||||
})
|
||||
return lines, incomplete
|
||||
|
||||
|
||||
HOST, PORT = "127.0.0.1", 5000
|
||||
|
||||
|
||||
def server_answers(host=HOST, port=PORT, timeout=0.4):
|
||||
"""Is a server already listening? Windows lets a SECOND server bind the same port instead of refusing
|
||||
it (SO_REUSEADDR), so every extra start left another instance running and Windows decided which one
|
||||
answered — after an update often the OLD one, still serving the previous code."""
|
||||
import socket
|
||||
with socket.socket() as s:
|
||||
s.settimeout(timeout)
|
||||
return s.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
def main():
|
||||
import threading
|
||||
import webbrowser
|
||||
url = f"http://{HOST}:{PORT}"
|
||||
if server_answers(): # already running: just open the window, never start twice
|
||||
webbrowser.open(url)
|
||||
return
|
||||
app = create_app()
|
||||
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
||||
app.run(host=HOST, port=PORT, debug=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user