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
+128
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user