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>
219 lines
12 KiB
Python
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}
|