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
+218
@@ -0,0 +1,218 @@
|
||||
"""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}
|
||||
Reference in new issue
Block a user