Tischbelegung: Laufkundschaft und „Tisch frei"

Wer sitzt gerade wo — ohne einen einzigen personenbezogenen Wert, damit ein Bildschirm im Gastraum keinen Schlüssel für die Gästeliste braucht.

Für Entwickler

Voraussetzungen

  • Recht `tables:read` zum Lesen, `tables:write` zum Melden und Schliessen
  • Plan-Merkmal „Tischplan"

EINE BELEGUNG IST KEINE RESERVIERUNG. Sie sagt nur: dieser Tisch ist ab diesem Zeitpunkt besetzt. Kein Name, keine Kontaktdaten, kein Gastprofil — genau deshalb hängt sie am Recht `tables` und nicht an `reservations`. Ein Bildschirm im halböffentlichen Gastraum soll keinen Schlüssel brauchen, der die Gästeliste mitliest.

`occupiedUntil: null` HEISST: DER TISCH IST NOCH BESETZT. Genau darauf sieht der Bildschirm im Lokal. Eine offene Belegung sperrt den Tisch ausserdem in der Verfügbarkeitsrechnung — wer sie nicht schliesst, nimmt dem Betrieb den Tisch für den Rest des Tages aus dem Online-Verkauf. Deshalb ist „Tisch frei" ein eigener, einfacher Aufruf und kein Formular.

EIN TISCH KANN NICHT ZWEIMAL GLEICHZEITIG BESETZT SEIN. Gibt es bereits eine offene Belegung, antwortet das Anlegen mit 409 `conflict` und nennt unter `occupancyId` die bestehende Zeile. Ohne diese Prüfung erzeugte jeder Netzwiederholversuch ohne Idempotenzschlüssel eine zweite offene Belegung — und die bliebe stehen, wenn „Tisch frei" nur die erste schliesst.

`occupiedFrom` DARF NICHT IN DER ZUKUNFT LIEGEN. Ein Zeitpunkt in der Zukunft wäre eine Reservierung, keine Belegung — und eine Belegung sperrt den Tisch ohne Gastdaten und ohne Endzeit. Ohne Angabe gilt „jetzt". Eine Toleranz von einer Minute fängt Uhrzeitunterschiede zwischen Kasse und Server ab.

ES GIBT KEIN LÖSCHEN. Eine gelöschte Belegung wäre eine Lücke in der Tagesauswertung, während die geschlossene Zeile erzählt, wie lange der Tisch wirklich besetzt war. `occupiedUntil: null` im `PATCH` nimmt eine Freigabe zurück — der Tisch ist doch noch besetzt.

OHNE `open`-FILTER LIEFERT DIE LISTE NUR DIE OFFENEN BELEGUNGEN. Das ist die Frage des Bildschirms im Lokal, und sie ist billig. `open=false` liefert die abgeschlossenen — für die Auswertung „wie lange sass der Tisch". Ein „alles" gibt es bewusst nicht: unbegrenzte Historie ohne Zeitfenster ist die teuerste aller Abfragen.

Die Routen

GET/api/v1/table-occupancies

Welche Tische gerade besetzt sind — oder, mit `open=false`, wie lange sie besetzt waren.

Rechte

tables:read

Plan-Merkmal reservations_tableplan — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Abfrageparameter

NameTypBedeutung
opentrue | falseVorgabe: trueOhne Angabe nur die OFFENEN (`occupiedUntil` ist `null`). `false` liefert ausschliesslich die abgeschlossenen.
dateYYYY-MM-DDNur Belegungen, die an diesem Kalendertag des Betriebs BEGONNEN haben.
tableIdUUIDNur dieser Tisch. Eine Kennung, die nicht zu diesem Betrieb gehört, ist 400.
limitinteger 1–200Vorgabe: 50Zeilen je Seite. Ein Wert über 200 ist 400 und nicht etwa stillschweigend gekappt — sonst hielte der Aufrufer eine halbe Antwort für eine ganze.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort, unverändert.
dirasc | descVorgabe: descSortierung nach `occupiedFrom`. `desc` ist die Vorgabe, weil die jüngste Belegung die interessante ist.

Mögliche Fehler

  • validationUnbekannter Parameter, fremder `tableId`, unlesbarer oder fremder `cursor`.
  • forbiddenDem Schlüssel fehlt `tables:read`.
  • plan_upgrade_requiredDer Plan des Betriebs enthält den Tischplan nicht.
  • Flacher Umschlag: `data`, `nextCursor`, `hasMore`, `limit`.

POST/api/v1/table-occupancies

„Gast sitzt" melden — Laufkundschaft, die ohne Reservierung an einem Tisch Platz nimmt.

Rechte

tables:write

Plan-Merkmal reservations_tableplan — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Kopfzeilen

NameTypBedeutung
Idempotency-Keystring bis 255 ZeichenFreiwillig. Ohne ihn fängt die Konfliktprüfung den Wiederholversuch ab — mit ihm bekommen Sie dieselbe Antwort statt eines 409.

Felder im Rumpf

NameTypBedeutung
tableIdPflichtUUIDDer Tisch. Er muss zu diesem Betrieb gehören, sonst 400.
partySizeinteger 1–200Wie viele Personen sitzen. Freiwillig — für die Auslastungsauswertung aber wertvoll.
notesstring bis 500 Zeichen oder nullKurzer Vermerk für den Service.
occupiedFromISO-8601 mit ZoneVorgabe: jetztWann der Gast Platz genommen hat. Darf nicht in der Zukunft liegen — das wäre eine Reservierung.

Mögliche Fehler

  • validationFremder oder unbekannter `tableId`, `occupiedFrom` in der Zukunft, unbekanntes Feld im Rumpf.
  • conflictFür diesen Tisch gibt es bereits eine offene Belegung. Die Antwort nennt `occupancyId` — schliessen Sie erst diese.
  • forbiddenDem Schlüssel fehlt `tables:write`.
  • Antwortet mit 201 und `{ "occupancy": { … } }`. `isWalkIn` ist dabei immer `true`.

PATCH/api/v1/table-occupancies/{id}

„Tisch frei" melden — oder eine Freigabe zurücknehmen, weil der Gast doch noch sitzt.

Rechte

tables:write

Plan-Merkmal reservations_tableplan — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Pfad

NameTypBedeutung
{id}PflichtUUIDKennung der Belegung. Eine fremde Kennung ergibt 404.

Felder im Rumpf

NameTypBedeutung
occupiedUntilISO-8601 mit Zone oder nullDer Zeitpunkt, zu dem der Tisch frei wurde — DAS ist „Tisch frei". `null` nimmt die Freigabe zurück. Ein Wert vor `occupiedFrom` ist 400.
partySizeinteger 1–200 oder nullPersonenzahl nachtragen oder berichtigen.
notesstring bis 500 Zeichen oder nullVermerk ändern; `null` oder `""` leert ihn.

Mögliche Fehler

  • nothing_to_writeDer Rumpf enthält kein änderbares Feld. Für „Tisch frei" genügt `{"occupiedUntil": "<Zeitpunkt>"}`.
  • validation`occupiedUntil` liegt vor `occupiedFrom`, oder ein Feld ist unbekannt.
  • not_foundDie Kennung gehört zu keiner Belegung dieses Betriebs.
  • forbiddenDem Schlüssel fehlt `tables:write`.
  • Kein `DELETE`: die geschlossene Zeile trägt die Tagesauswertung.

Codebeispiele

curl — was gerade besetzt ist
curl
curl -sS https://tactictable.com/api/v1/table-occupancies \
  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM"
Ohne Parameter: nur die offenen Belegungen, jüngste zuerst. Das ist der Aufruf, den ein Bildschirm am Empfang alle paar Sekunden macht.
Antwort 200 — GET /api/v1/table-occupancies
JSON
{  "data": [    {      "id": "9f289eb9-7031-50d7-b4d8-54742da1dab6",      "tableId": "653d49c8-cd70-53ea-839f-d3131a574417",      "tableName": "7",      "areaId": "ce2038c4-0bb6-51d7-b705-dc831177a2d8",      "occupiedFrom": "2026-09-14T17:41:03.120Z",      "occupiedUntil": null,      "partySize": 2,      "isWalkIn": true,      "notes": null,      "guestId": null,      "timezone": "Europe/Vienna",      "createdAt": "2026-09-14T17:41:03.120Z"    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 50}
Zeitpunkte sind hier UTC mit `Z` — anders als bei der Reservierung gibt es keinen Kalendertag. `timezone` hängt trotzdem an der Zeile, damit sich die Ortszeit ohne zweite Abfrage bilden lässt.
curl — „Gast sitzt" und „Tisch frei"
curl
# Gast setzt sich an Tisch 7.curl -sS -X POST https://tactictable.com/api/v1/table-occupancies \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -d '{"tableId": "653d49c8-cd70-53ea-839f-d3131a574417", "partySize": 2}' # Zwei Stunden spaeter: Tisch frei.curl -sS -X PATCH \  https://tactictable.com/api/v1/table-occupancies/9f289eb9-7031-50d7-b4d8-54742da1dab6 \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -d '{"occupiedUntil": "2026-09-14T19:52:00Z"}'
Solange nicht geschlossen wird, gilt der Tisch als besetzt und fällt aus der Verfügbarkeitsrechnung. Ein vergessenes „Tisch frei" kostet den Betrieb den Online-Verkauf dieses Tisches für den Rest des Tages.
Antwort 409 — der Tisch ist schon belegt
JSON
{  "error": "conflict",  "message": "Dieser Tisch ist bereits als belegt gemeldet.",  "reason": "table_occupied",  "occupancyId": "9f289eb9-7031-50d7-b4d8-54742da1dab6",  "docs": "https://tactictable.com/dokumentation/api/fehler/conflict",  "requestId": "req_8f31c0a94d2b47e6ba05"}
`occupancyId` ist die bestehende offene Zeile. Schliessen Sie sie mit einem `PATCH`, bevor Sie neu melden — oder werten Sie den 409 als „ist schon gemeldet" und tun nichts.
TypeScript — Bildschirm im Gastraum
TypeScript
interface Belegung {    id: string    tableId: string    tableName: string | null    areaId: string | null    occupiedFrom: string    occupiedUntil: string | null    partySize: number | null    isWalkIn: boolean} /** Kennungen der Tische, die JETZT besetzt sind. */export async function besetzteTische(token: string): Promise<Set<string>> {    const antwort = await fetch('https://tactictable.com/api/v1/table-occupancies?limit=200', {        headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },    })    if (!antwort.ok) throw new Error('HTTP ' + antwort.status)     const seite = (await antwort.json()) as { data: Belegung[] }    return new Set(seite.data.map((b) => b.tableId))} /** Wie lange der Tisch schon sitzt, in Minuten. */export function dauerMinuten(b: Belegung, jetzt: Date = new Date()): number {    const von = new Date(b.occupiedFrom).getTime()    const bis = b.occupiedUntil ? new Date(b.occupiedUntil).getTime() : jetzt.getTime()    return Math.max(0, Math.round((bis - von) / 60000))}
Diese Antwort enthält keinen einzigen personenbezogenen Wert — deshalb darf der Schlüssel dieses Bildschirms `tables:read` tragen und sonst nichts. Der Tischplan selbst kommt aus `GET /api/v1/tables`.
Python — Tisch besetzen, Konflikt vertragen
Python
import json import requests  def tisch_besetzen(sitzung: requests.Session, tisch_id: str, personen: int) -> dict:    antwort = sitzung.post(        "https://tactictable.com/api/v1/table-occupancies",        data=json.dumps({"tableId": tisch_id, "partySize": personen}),        headers={"Content-Type": "application/json"},        timeout=20,    )     if antwort.status_code == 409:        # Schon gemeldet — das ist kein Ausfall, sondern der Zustand,        # den wir herstellen wollten.        return {"bereits_belegt": True, "id": antwort.json().get("occupancyId")}     if antwort.status_code != 201:        rumpf = antwort.json()        raise RuntimeError("{}: {}".format(rumpf["error"], rumpf["message"]))     return antwort.json()["occupancy"]  def tisch_freigeben(sitzung: requests.Session, belegung_id: str, zeitpunkt: str) -> dict:    antwort = sitzung.patch(        "https://tactictable.com/api/v1/table-occupancies/" + belegung_id,        data=json.dumps({"occupiedUntil": zeitpunkt}),        headers={"Content-Type": "application/json"},        timeout=20,    )    antwort.raise_for_status()    return antwort.json()["occupancy"]
Der 409 wird hier als erreichter Zustand gewertet, nicht als Fehler. Eine Kasse, die bei jedem Bon „Gast sitzt" meldet, erzeugt ihn zwangsläufig — und soll deshalb nicht bei jedem zweiten Bon einen Fehler protokollieren.