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:
BlackMarkandClaude Opus 5.5 committed 2026-11-02 16:52:27 +01:00
commit 45424e6fd6
47 files changed
+6701

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
"""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,
}