Files
vermietungs-tool/app/plan.py
T
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

129 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Belegungsplan: a rooms × days occupancy timeline built from the guest register (the house's
who-is-present record; the ledger is billing and stays out of this).
Date semantics are half-open [arrival, departure): the departure day itself is free — it is the
'Von' of the follow-up row when a weekly renter is carried forward, so the bars tile without
overlapping. An open departure means "still here" only while the arrival is recent; an open stay
older than a year is a forgotten departure (same 365-day rule as the TVB Meldung) and is left to
the Datenprüfung instead of blocking a room forever.
"""
import datetime
from app import db, i18n
FAR = "9999-12-31"
STALE_AFTER_DAYS = 365 # keep in sync with tvb.STALE_AFTER_DAYS
MONTHS = i18n.t("months")
MONTHS_SHORT = i18n.t("months_short")
WEEKDAYS = i18n.t("weekdays_short")
def default_start(today=None):
"""Monday of last week — a little context behind, most of the window ahead."""
today = today or datetime.date.today()
return today - datetime.timedelta(days=today.weekday() + 7)
def _pack_lanes(stays):
"""Greedy interval packing: each stay goes into the first lane free at its arrival, so
concurrent occupants of a room stack while back-to-back ones share a line."""
lane_ends, out = [], []
for st in stays:
dep = st["departure"] or FAR
for i, end in enumerate(lane_ends):
if end <= st["arrival"]:
lane_ends[i] = dep
out.append((st, i))
break
else:
lane_ends.append(dep)
out.append((st, len(lane_ends) - 1))
return out, max(len(lane_ends), 1)
def build(conn, start, days, today=None):
today = today or datetime.date.today()
end = start + datetime.timedelta(days=days)
s_iso, e_iso, t_iso = start.isoformat(), end.isoformat(), today.isoformat()
fresh = (today - datetime.timedelta(days=STALE_AFTER_DAYS)).isoformat()
stays = db.rows(conn, """
SELECT s.id, s.room_id, s.arrival, s.departure, s.sheet_number,
COALESCE(TRIM(g.last_name || ' ' || COALESCE(g.first_name,'')), s.label) AS name,
cl.name AS employer
FROM stay s LEFT JOIN guest g ON g.id=s.guest_id
LEFT JOIN client cl ON cl.id=s.employer_client_id
WHERE s.source_sheet='guestsheet' AND s.arrival IS NOT NULL
AND s.arrival < ? AND COALESCE(s.departure, ?) > ?
AND (s.departure IS NOT NULL OR s.arrival >= ?)
ORDER BY s.arrival NULLS FIRST, COALESCE(s.departure, ?), s.id""",
(e_iso, FAR, s_iso, fresh, FAR))
per_room, unplaced = {}, []
for st in stays:
if st["room_id"] is None:
unplaced.append(dict(st))
else:
per_room.setdefault(st["room_id"], []).append(st)
# today's occupants per room, independent of the visible window
counts = {r[0]: r[1] for r in db.rows(conn, """
SELECT room_id, COUNT(*) FROM stay
WHERE source_sheet='guestsheet' AND room_id IS NOT NULL AND arrival IS NOT NULL
AND arrival <= ? AND COALESCE(departure, ?) > ?
AND (departure IS NOT NULL OR arrival >= ?)
GROUP BY room_id""", (t_iso, FAR, t_iso, fresh))}
rooms = []
for r in db.rows(conn, """SELECT r.id, r.number, r.category, r.capacity, r.long_term,
u.code AS unit FROM room r
LEFT JOIN unit u ON u.id=r.unit_id ORDER BY r.number"""):
packed, lanes = _pack_lanes(per_room.get(r["id"], []))
bars = []
for st, lane in packed:
dep = st["departure"] or FAR
a, b = max(st["arrival"], s_iso), min(dep, e_iso)
c1 = (datetime.date.fromisoformat(a) - start).days + 1
c2 = max((datetime.date.fromisoformat(b) - start).days + 1, c1 + 1)
state = ("past" if st["departure"] and st["departure"] <= t_iso
else "future" if st["arrival"] > t_iso else "current")
bars.append({
"stay_id": st["id"], "name": st["name"] or "?", "employer": st["employer"],
"sheet_number": st["sheet_number"], "arrival": st["arrival"],
"departure": st["departure"], "col": c1, "col_end": c2, "lane": lane + 1,
"state": state, "clip_start": st["arrival"] < s_iso, "clip_end": dep > e_iso,
"open_end": st["departure"] is None,
})
rooms.append({**dict(r), "bars": bars, "lanes": lanes,
"today_count": counts.get(r["id"], 0), "floor": str(r["number"])[:1]})
day_list, months = [], []
for i in range(days):
d = start + datetime.timedelta(days=i)
day_list.append({"iso": d.isoformat(), "dom": d.day, "wd": WEEKDAYS[d.weekday()],
"weekend": d.weekday() >= 5, "today": d == today,
"monday": d.weekday() == 0, "month_start": d.day == 1,
"label": f"{d.day:02d}.{d.month:02d}."})
m = f"{MONTHS[d.month - 1]} {d.year}"
if months and months[-1]["label"] == m:
months[-1]["span"] += 1
else:
months.append({"label": m, "short": MONTHS_SHORT[d.month - 1], "span": 1})
for m in months:
# pick a label that fits the span so it can't run into the next month's divider —
# 810px is the grid width at 200% display scaling, the narrowest supported case
est = m["span"] * 810 / days
m["text"] = m["label"] if est >= 95 else (m["short"] if est >= 40 else "")
live = [r for r in rooms if not r["long_term"]]
return {
"rooms": rooms, "days": day_list, "months": months, "n_days": days,
"start": start, "end": end, "today": today,
"last": (end - datetime.timedelta(days=1)).isoformat(),
"prev_start": (start - datetime.timedelta(days=7)).isoformat(),
"next_start": (start + datetime.timedelta(days=7)).isoformat(),
"guests_today": sum(r["today_count"] for r in rooms),
"free_beds": sum(max(0, (r["capacity"] or 0) - r["today_count"]) for r in live),
"unplaced": unplaced,
}