Files
BlackMarkandClaude Opus 5.5 45424e6fd6 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>
2026-11-02 16:52:27 +01:00

219 lines
12 KiB
Python

"""Monthly TVB Sammelmeldung (Aufenthaltsabgabe) figures, computed from the guest register.
The rules of the report:
- a night belongs to the month its evening falls in (the night onto the 1st counts to the previous month);
- "Personen" = NEW Gästeblätter that month — a guest already present counts 0 persons, only nights;
a room change keeps the sheet number (no new person), a vacation gap issues a new sheet (counts again);
- the Land is the HAUPTWOHNSITZ: the register's HWS flag set means the main residence is the house itself
(→ Ö-Tirol); Austria splits by Bundesland (postcode), Germany reports per postcode region;
- stays of more than 10 nights in total are exempt ("Freie"), shorter ones owe the levy ("Pflichtige");
a still-open stay at the month border is assumed to run long (frei) but flagged for a human decision.
"""
import datetime
import re
from app import compute, db, i18n
EXEMPT_OVER_NIGHTS = compute.TOURISM_EXEMPT_OVER_NIGHTS # > 10 nights total → exempt from the levy
STALE_AFTER_DAYS = 365 # an open stay this much older than the month is a missing departure, not a guest
AT_STATES = ((1000, 1999, "Wien"), (2000, 3999, "Niederösterreich"), (4000, 4999, "Oberösterreich"),
(5000, 5999, "Salzburg"), (6000, 6699, "Tirol"), (6700, 6999, "Vorarlberg"),
(7000, 7999, "Burgenland"), (8000, 8999, "Steiermark"), (9000, 9999, "Kärnten"))
# Feratel's German "Stat. Land" dropdown offers 7 regions (the Statistik-Austria grouping), spelled
# exactly as below. The Bundesland is resolved from the PLZ first, then folded into its region.
DE_REGIONS = {"Bayern": "Bayern", "Baden-Württemberg": "Baden Württemberg",
"Nordrhein-Westfalen": "Nordrhein-Westfalen", "Berlin": "Berlin",
"Niedersachsen": "Norddeutschland", "Hamburg": "Norddeutschland",
"Bremen": "Norddeutschland", "Schleswig-Holstein": "Norddeutschland",
"Hessen": "Mitteldeutschland", "Rheinland-Pfalz": "Mitteldeutschland",
"Saarland": "Mitteldeutschland",
"Brandenburg": "Ostdeutschland", "Mecklenburg-Vorpommern": "Ostdeutschland",
"Sachsen": "Ostdeutschland", "Sachsen-Anhalt": "Ostdeutschland",
"Thüringen": "Ostdeutschland"}
# German Leitregion -> predominant Bundesland. A few border regions genuinely cross states
# (e.g. Aschaffenburg 637xx is Bayern inside the Hessen block), so every German row carries its PLZ
# as a note to check — at a few German guests a year that is the honest cut.
DE_STATES = (
(1000, 2999, "Sachsen"), (3000, 3999, "Brandenburg"), (4000, 5999, "Sachsen"),
(6000, 6999, "Sachsen-Anhalt"), (7000, 7999, "Thüringen"), (8000, 9999, "Sachsen"),
(10000, 14330, "Berlin"), (14400, 16999, "Brandenburg"), (17000, 19999, "Mecklenburg-Vorpommern"),
(20000, 22999, "Hamburg"), (23000, 25999, "Schleswig-Holstein"),
(26000, 27499, "Niedersachsen"), (27500, 27599, "Bremen"), (27600, 27999, "Niedersachsen"),
(28000, 28999, "Bremen"), (29000, 31999, "Niedersachsen"), (32000, 33999, "Nordrhein-Westfalen"),
(34000, 36999, "Hessen"), (37000, 38999, "Niedersachsen"), (39000, 39999, "Sachsen-Anhalt"),
(40000, 48999, "Nordrhein-Westfalen"), (49000, 49999, "Niedersachsen"),
(50000, 53999, "Nordrhein-Westfalen"), (54000, 56999, "Rheinland-Pfalz"),
(57000, 59999, "Nordrhein-Westfalen"), (60000, 65999, "Hessen"), (66000, 66999, "Saarland"),
(67000, 67999, "Rheinland-Pfalz"), (68000, 79999, "Baden-Württemberg"),
(80000, 87999, "Bayern"), (88000, 89999, "Baden-Württemberg"),
(90000, 97999, "Bayern"), (98000, 99999, "Thüringen"),
)
def _date(s):
return datetime.date.fromisoformat(s[:10]) if s else None
def _at_state(postcode):
digits = "".join(ch for ch in str(postcode or "") if ch.isdigit())[:4]
if len(digits) == 4:
n = int(digits)
for lo, hi, name in AT_STATES:
if lo <= n <= hi:
return name
return None
def _de_state(postcode):
digits = "".join(ch for ch in str(postcode or "") if ch.isdigit())
if len(digits) == 4: # a lost leading zero (02763 -> 2763)
digits = "0" + digits
if len(digits) == 5:
n = int(digits)
for lo, hi, name in DE_STATES:
if lo <= n <= hi:
return name
return None
def _land(row):
"""The reporting Land for a guest, in the report's shorthand ('Ö-Wien'). Returns (land, warning?)."""
if row["hauptwohnsitz"] == 1: # main residence IS the house → Tirol
return "Ö-Tirol", None
country = (row["home_country"] or "").strip()
if not country:
nat = (row["nationality"] or "").strip()
if nat:
return nat, i18n.t("tvb_warn_nationality_used")
return "Unbekannt", i18n.t("tvb_warn_no_country")
if country == "Österreich":
state = _at_state(row["home_postcode"])
if state:
return f"Ö-{state}", None
return "Ö-?", i18n.t("tvb_warn_at_postcode")
if country == "Deutschland":
state = _de_state(row["home_postcode"])
if state: # grouped exactly like Feratel's dropdown
return f"{DE_REGIONS[state]} (DE)", \
i18n.t("tvb_warn_de_border", postcode=row["home_postcode"], state=state)
return "Deutschland (?)", i18n.t("tvb_warn_de_postcode")
return country, None
def _sheet_order(sheet):
"""Gästeblatt numbers in numeric order ('98' before '613'), a row without one last — the order the
paper sheets are filed in."""
m = re.match(r"\d+", sheet or "")
return (0, int(m.group())) if m else (1, 0)
def _nights_in(arrival, departure, month_start, month_end):
"""Nights whose evening falls inside the month; the departure day itself is no night."""
a = max(arrival, month_start)
b = min(departure - datetime.timedelta(days=1), month_end) if departure else month_end
return max(0, (b - a).days + 1)
def monthly_report(conn, year, month):
"""The Sammelmeldung for one month: per current unit the room-by-room detail rows (to check against
the paper sheets) and the per-Land Pflichtige/Freie summary exactly as it is typed into Feratel."""
month_start = datetime.date(year, month, 1)
rate = compute.tourism_rate(month_start.isoformat())
month_end = (month_start + datetime.timedelta(days=45)).replace(day=1) - datetime.timedelta(days=1)
stale_before = month_start - datetime.timedelta(days=STALE_AFTER_DAYS)
stays = db.rows(conn, """
SELECT s.id, s.arrival, s.departure, s.sheet_number, s.hauptwohnsitz, s.guest_id,
COALESCE(s.unit_id, rm.unit_id) AS unit_id, rm.number AS room,
TRIM(g.last_name || ' ' || COALESCE(g.first_name, '')) AS name,
g.home_country, g.home_postcode, g.nationality
FROM stay s JOIN guest g ON s.guest_id = g.id LEFT JOIN room rm ON s.room_id = rm.id
WHERE s.source_sheet = 'guestsheet' AND s.arrival IS NOT NULL
AND s.arrival <= ? AND (s.departure IS NULL OR s.departure > ?)
ORDER BY rm.number NULLS FIRST, s.arrival NULLS FIRST, s.id""", (month_end.isoformat(), month_start.isoformat()))
# current units; predecessors report under their successor
units = {u["id"]: dict(u) for u in db.rows(conn, "SELECT * FROM unit ORDER BY id")}
def current_unit(uid):
seen = set()
while uid in units and units[uid]["succeeded_by_id"] and uid not in seen:
seen.add(uid)
uid = units[uid]["succeeded_by_id"]
return uid
# group rows by Gästeblatt (room changes share the number); a sheetless row is its own group
groups = {}
for s in stays:
key = (s["guest_id"], s["sheet_number"]) if s["sheet_number"] else ("stay", s["id"])
groups.setdefault(key, []).append(s)
report = {u["code"]: {"code": u["code"], "name": u["name"], "tvb_number": u["tvb_number"],
"rows": [], "warnings": [], "summary": {}}
for u in units.values() if u.get("is_current")}
for rows in groups.values():
first = min(rows, key=lambda r: r["arrival"])
arrival = _date(first["arrival"])
is_open = any(r["departure"] is None for r in rows)
unit = units.get(current_unit(rows[0]["unit_id"]))
target = report.get(unit["code"]) if unit else None
if target is None: # no unit resolvable — surface, never guess a Betrieb
for u in report.values():
u["warnings"].append(i18n.t("tvb_warn_no_unit", name=first["name"], arrival=f"{arrival:%d.%m.%Y}"))
continue
if is_open and arrival < stale_before: # forgotten departure years ago, not a guest —
continue # surfaced on the Datenprüfung page, not here
land, land_warn = _land(first)
month_nights = sum(_nights_in(_date(r["arrival"]), _date(r["departure"]), month_start, month_end)
for r in rows)
if month_nights == 0:
continue
total_nights = sum((( _date(r["departure"]) or month_end + datetime.timedelta(days=1))
- _date(r["arrival"])).days for r in rows)
person = 1 if arrival >= month_start else 0
exempt = total_nights > EXEMPT_OVER_NIGHTS
note = None
if not exempt and is_open: # foreseeably long stays are frei
exempt, note = True, i18n.t("tvb_note_still_here", nights=total_nights)
if land_warn:
note = (note + "; " if note else "") + land_warn
for r in sorted(rows, key=lambda r: r["arrival"]):
n = _nights_in(_date(r["arrival"]), _date(r["departure"]), month_start, month_end)
if n == 0:
continue
target["rows"].append({
"room": r["room"], "name": r["name"], "land": land,
"person": person if r is first else 0, "nights": n,
# the whole stay next to the month's share: a stay across a month boundary looks like a
# mismatch against the Gästeblatt otherwise (24.08.-02.09. = 9 Nächte, 8 of them in
# August, 1 in September)
"arrival": r["arrival"], "departure": r["departure"], "total_nights": total_nights,
"sheet": r["sheet_number"], "exempt": exempt, "note": note})
note = None # print group notes once
key = (land, exempt)
agg = target["summary"].setdefault(key, {"land": land, "exempt": exempt, "persons": 0, "nights": 0})
agg["persons"] += person
agg["nights"] += month_nights
month_names = i18n.t("months")
out = []
for code in sorted(report):
u = report[code]
u["rows"].sort(key=lambda r: (str(r["room"] or "~"), _sheet_order(r["sheet"]), r["arrival"], r["name"]))
u["pflichtig"] = sorted((v for v in u["summary"].values() if not v["exempt"]), key=lambda v: v["land"])
u["frei"] = sorted((v for v in u["summary"].values() if v["exempt"]), key=lambda v: v["land"])
for part in ("pflichtig", "frei"):
u[part + "_sum"] = {"persons": sum(v["persons"] for v in u[part]),
"nights": sum(v["nights"] for v in u[part])}
u["betrag"] = round(u["pflichtig_sum"]["nights"] * rate, 2)
u["new_sheets"] = u["pflichtig_sum"]["persons"] + u["frei_sum"]["persons"]
del u["summary"]
out.append(u)
return {"year": year, "month": month, "month_start": month_start, "rate": rate,
"month_name": f"{month_names[month - 1]} {year}", "units": out}