"""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}