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>
129 lines
6.0 KiB
Python
129 lines
6.0 KiB
Python
"""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,
|
||
}
|