docs: include previously ignored documentation files

This commit is contained in:
Björn Fromme
2026-08-24 09:20:56 +02:00
parent 1796ec4964
commit 83b8ba7f8d
12 changed files with 15799 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
# Travel catalog — direct MSSQL queries (to replace the XML export)
> **Authority: [`docs/buspro-database-reference.md`](../buspro-database-reference.md).**
> Schema semantics, conventions and confidence marks live there and win on any conflict.
> This file is retained as a **query cookbook** — it holds worked SQL (included-services
> A2a/A2b, the full room-pricing chain B3) not reproduced in the reference.
Purpose: reproduce, straight from the BusPro MSSQL tables, the data the Symfony app
currently parses from the `Ziel_*.xml` exports, so the Go proxy can serve it without
the SFTP/XML round-trip. Two read paths:
- **Query A — travel list:** every bookable departure + product + dates (+ hotels,
+ a minimal bookable-services list).
- **Query B — detail:** everything for one selected `(date, hotel)` combination.
Validated against the live DB using the sample `var/xmlexport/Ziel_1121.xml`
(Produkt `1121`, departure `Reise.IDReise=12665`, hotel `Adresse.IDAdresse=224923`,
room-type `Zimmerart 1413`, pickup `Zustieg 494`). Columns verified in
`docs/buspronet-schema/*.csv`. Header, services, pickups, hotels, room pricing,
availability and tags are all confirmed against live data; the "included services"
list for Query A (§A2) is defined as price-`0.00` services + the 10 hardcoded
"Leistungen" selection groups (ids baked in). Nothing left to resolve.
## The naming trap (read first)
The XML export **renames** the core entities — do not map by name:
| XML term | Real table | Notes |
|---|---|---|
| `<reise idbuspro=1121>` | **`Produkt`** | the travel *product*: `IDProdukt`, `Code`, `Bezeichnung` |
| `<termin idprodukt=1121 idbuspro=12665>` | **`Reise`** | the **dated departure**: `IDReise=12665`, `IDProdukt_FS=1121`, `Code`, `TerminVon/Bis`, `ReiseArt`, `Status`, `StornoDatum` |
| DB `Termin` table | — | **ignore**: generic appointments (odometer/repairs) |
| `<leistung>` (lei_befoerderung / lei_sonstiges) | `ReiseLeistung → Leistung` | `Leistung.Art` = `BEF` / `SON`; price via `LeistungPreis` |
| `<hotel idbuspro=224923>` + rooms | `ReisePartner` (+ `Partner*` subsystem) | **not** `ReiseLeistung`; keyed by the hotel `Adresse` |
| `<zustieg>` | `LeistungBefoerderung.ZustiegIDs``Zustieg` (+ `ZustiegZeitplan` for the time) | |
| `<selektion>` / `<selektiongruppe>` | `ReiseSelektionen → Selektionsstamm` (+ `Selektionsgruppe → Selektionsgruppenstamm`) | |
`Produkt 1—N Reise`. **`Reise.IDReise` is the "date id"** and the anchor for all
per-departure data.
**Two distinct status axes** — don't conflate them:
1. **Departure lifecycle**`Reise.Status` (`nvarchar(2)`), observed `''` ≈ 9767,
`S` ≈ 945, `L` ≈ 845. Confirmed against the live DB:
- `''` = active/bookable.
- `S` = **Storno** (cancelled): every `S` row has `StornoDatum` set.
- `L` = **template / future master** (not a real sellable departure): every `L`
row has `StornoDatum NULL`, far-future `TerminVon` (20292035) and template-style
`Code`s (`MRAGRMP`, no date suffix). `Leistung.Status` mirrors this (`''` = 196 504,
`L` = 1 672 — the leistungen attached to those template departures).
2. **Availability / booking status** — booking-state codes, **not** `Leistung.Status`
(which only holds `''`/`L`). Canonical dictionary = **`dbo.ArtBuchungsStatus`**
(`Code`), confirmed values:
| Code | Kurztext | meaning |
|---|---|---|
| `F` | Fest | firm/final booking (available) |
| `O` | Option | held option |
| `A` | Anfrage | on request / inquiry-only (groups-inquiry path) |
| `W` | Warteliste | waitlist |
| `S` | Storno | cancelled |
| `U` | Umbuchung | rebooking |
The **working column** for hotel availability is **`PartnerKontingent.BuchungsStatus`**
(`nvarchar(2)`) — confirmed to carry these: observed `''` ≈ 19 664 (free/Fest),
`S` ≈ 12 215 (stop), `A` ≈ 2 060 (on request). So per room/date: `''` = bookable,
`S` = not bookable, `A` = inquiry-only. (The same codes also appear via
`…IDArtBuchungsstatus_FS` on individual bookings such as `ZusatzZimmer`.) Transport
`<status_hin>`/`<status_rueck>` is not in `Leistung.Status`; treat it as derived from
the transport `Kontingent` count.
Bookable filter (departure-level): `Reise.Status = '' AND Reise.StornoDatum IS NULL`
(excludes both `S` Storno and `L` templates), then apply `PartnerKontingent.BuchungsStatus`
per room/date (exclude `S`, treat `A` as inquiry-only).
Note the XML repeats `lei_sonstiges` under each hotel; in the DB the services exist
**once** on the departure (`ReiseLeistung`).
Conventions: ids `bigint``int64`; prices `decimal(18,2)`; `_FS` = foreign key (many
soft/unenforced — join defensively); bracket non-ASCII columns (`[Reisebüro]`).
---
## Query A — travel list
### A1. Departures + product + dates (header)
```sql
SELECT
p.IDProdukt AS ProductId, p.Code AS ProductCode, p.Bezeichnung AS ProductLabel,
r.IDReise AS DateId, r.Code AS DateCode,
r.ReiseArt AS TravelType, -- XML reiseart, e.g. 'F'
r.Bezeichnung AS DateLabel,
r.TerminVon AS DateFrom, r.TerminBis AS DateTo
FROM dbo.Reise AS r
JOIN dbo.Produkt AS p ON p.IDProdukt = r.IDProdukt_FS
WHERE r.Status = '' AND r.StornoDatum IS NULL -- bookable (confirm status codes)
ORDER BY r.TerminVon, p.Code;
```
Customer description (XML `<text>`) = `dbo.ReiseText.ExternText` (`IDReise_FS`) — join if wanted.
### A2. Included-services list per departure
"Minimum services" = the **included** services (no surcharge), from two sources that
get merged into one plain list per departure.
**A2a — included `Leistung`s (sales price = 0.00).** Priced services are optional
add-ons; the package-included ones have a `0.00` sales price (schema 1).
```sql
SELECT
rl.IDReise_FS AS DateId,
l.IDLeistung AS ServiceId,
l.Art AS Type, -- 'BEF' (transport) | 'SON' (other)
l.Unterart AS SubType, -- BUS/PKW/SPA/VER/SON/LVS/...
l.Bezeichnung AS Label
FROM dbo.ReiseLeistung rl
JOIN dbo.Leistung l ON l.IDLeistung = rl.IDLeistung_FS
JOIN dbo.LeistungPreis lp ON lp.IDLeistung_FS = l.IDLeistung
AND lp.IDArtPreisSchema_FS = 1 -- standard sales price (confirmed)
WHERE rl.IDReise_FS IN (@DateIds)
AND lp.Preis = 0.00 -- included = no surcharge
ORDER BY rl.IDReise_FS, l.Art, l.Unterart;
```
> Note: a `LEFT JOIN` would also surface services with **no** price row (NULL). Those
> are *not* the same as priced-at-zero — keep the inner join so "included" means an
> explicit `0.00`. (If BusPro models some included items as price-less rows, widen to
> `lp.Preis = 0.00 OR lp.IDLeistungPreis IS NULL` — confirm with the discovery query.)
**A2b — "Leistungen…" selection groups (plain list).** The catalog also lists included
services as selection tags under the "Leistungen" groups. Filter by a **hardcoded list
of `Selektionsgruppenstamm` ids** (configured in the proxy) rather than a label match —
the labels can be renamed, the ids are stable. Same join as B4; emit `Selektion` as the
plain text item.
```sql
SELECT rs.IDReise_FS AS DateId,
gs.IDSelektionsgruppenstamm AS GroupId,
gs.Selektionsgruppe AS GroupLabel, -- e.g. 'Leistungen inklusive'
s.Selektion AS Item
FROM dbo.ReiseSelektionen rs
JOIN dbo.Selektionsstamm s ON s.IDSelektionsstamm = rs.IDSelektionsstamm_FS
JOIN dbo.Selektionsgruppe sg ON sg.IDSelektionsstamm_FS = s.IDSelektionsstamm
JOIN dbo.Selektionsgruppenstamm gs ON gs.IDSelektionsgruppenstamm = sg.IDSelektionsgruppenstamm_FS
WHERE rs.IDReise_FS IN (@DateIds)
AND gs.IDSelektionsgruppenstamm IN (70, 89, 87, 88, 86, 7, 8, 90, 85, 71)
ORDER BY rs.IDReise_FS, gs.Position, sg.Position;
```
> The "Leistungen" `Selektionsgruppenstamm` ids (captured from the live DB — hardcode in
> the proxy; re-check if groups are added/renamed):
>
> | id | group | id | group |
> |---|---|---|---|
> | 70 | Leistungen | 86 | Leistungen Programm |
> | 89 | Leistungen Events | 7 | Leistungen Skipass |
> | 87 | Leistungen Kurse | 8 | Leistungen Sonstige |
> | 88 | Leistungen Personal | 90 | Leistungen Transport |
> | 85 | Leistungen Übernachtung | 71 | Leistungen Verpflegung |
> `LeistungPreis` has one row per `(Leistung, IDArtPreisSchema_FS)`; schema `1` is the
> standard sales price and reproduced the XML service prices exactly (13.90, 50.00, …).
---
## Query B — detail for one `(@DateId [, @HotelId])`
Run a header query + one query per child collection; assemble in Go.
### B1. Header — A1 with `WHERE r.IDReise = @DateId` (+ `ReiseText.ExternText`).
### B2. Services (transport + other, with direction & pickups) — **confirmed**
```sql
SELECT
l.IDLeistung AS ServiceId,
l.Art AS Type, -- BEF | SON
l.Unterart AS SubType,
l.Bezeichnung AS Label,
l.Pflichtmerkmal AS Mandatory,
l.Kontingent AS Available,
l.Alter_VON AS AgeFrom, l.Alter_BIS AS AgeTo,
l.Termin_VON AS DateFrom, l.Termin_BIS AS DateTo,
lb.Richtung_Hin_Rueck AS Direction, -- 'H' | 'R' (transport only)
lb.ZustiegIDs AS PickupIds, -- delimited list of dbo.Zustieg ids
lb.IDArtZustiegZeitplan_FS AS PickupTimePlanId,
lp.Preis AS Price
FROM dbo.ReiseLeistung rl
JOIN dbo.Leistung l ON l.IDLeistung = rl.IDLeistung_FS
LEFT JOIN dbo.LeistungBefoerderung lb ON lb.IDLeistung_FS = l.IDLeistung
LEFT JOIN dbo.LeistungPreis lp ON lp.IDLeistung_FS = l.IDLeistung
AND lp.IDArtPreisSchema_FS = 1
WHERE rl.IDReise_FS = @DateId
ORDER BY l.Art, l.Unterart, l.Bezeichnung;
```
`LeistungBefoerderung` present ⇒ transport (`lei_befoerderung`), else `lei_sonstiges`.
English texts (`<fremdsprache>`) = `dbo.LeistungText.ExternText` (validate).
### B2b. Pickup detail (resolve the `ZustiegIDs` list + time) — **confirmed**
For each id parsed from `lb.ZustiegIDs`:
```sql
SELECT z.IDZustieg AS PickupId, z.Zustiegcode AS Code,
z.Plz AS PostalCode, z.Ort AS City, z.Strasse AS Street,
CONVERT(time, zp.Uhrzeit) AS PickupTime
FROM dbo.Zustieg z
LEFT JOIN dbo.ZustiegZeitplan zp
ON zp.IDZustieg_FS = z.IDZustieg
AND zp.IDArtZustiegZeitplan_FS = @PickupTimePlanId -- from B2 (0 ⇒ no time)
WHERE z.IDZustieg = @PickupId;
```
Only the **time-of-day** of `Uhrzeit` is meaningful (the date part is a placeholder).
Verified: zustieg `494`, plan `2546``05:15`.
### B3. Hotels + rooms + board — **Partner subsystem**
The XML `<hotel idbuspro=224923>` is a hotel **`Adresse.IDAdresse`** (224923 =
"NEU Sportclub Waldschlössli Gruppen 2"), **not** a `ReisePartner`. Hotels are
assigned at the **product** level via the join table `PartnerProdukt
(IDProdukt_FS, IDAdresse_FS)` — that's why a departure (`IDReise`) has no `ReisePartner`
row. (`ReisePartner`/`IDArtVerpflegung_FS` is the booking-side hotel; for the *catalog*
use `PartnerProdukt`. `ProduktBeschreibung.IDAdresse_FS_Hotel` = the single featured hotel.)
```sql
-- hotels of the product (each IDAdresse_FS = an XML <hotel idbuspro>)
SELECT pp.IDAdresse_FS AS HotelAddressId, a.SuchName AS HotelName
FROM dbo.PartnerProdukt pp
JOIN dbo.Adresse a ON a.IDAdresse = pp.IDAdresse_FS
WHERE pp.IDProdukt_FS = @ProductId -- e.g. 1121
-- AND pp.IDAdresse_FS = @HotelAddressId
ORDER BY a.SuchName;
```
Board (`IDArtVerpflegung`) and stay dates aren't on `PartnerProdukt`; board comes from
the `PartnerPreisKategorie` rows below, and the stay span = the departure dates
(`Reise.TerminVon/TerminBis`, here `11.13.12.2026``Naechte = 2`).
Rooms / price / availability for a hotel address. The pricing fans out over
`room → price-category → season tafel → nights`; **validated** against the live DB
for hotel `224923`, room-type `Zimmerart 1413` (Code `MBZ`) → `IDArtPartnerPreisCode 38`
→ category `9478`, season `15420` (covers `2026-12-11`), `Naechte=2`, `EK_VK='VK'`,
`IDArtPreisSchema_FS=1`**`269,00`** (single clean row; same schema id `1` as the
`LeistungPreis` side). Map a room to its price category via `IDArtPartnerPreisCode_FS`.
(The XML headline `abpreis` of `289,00` = this room price + mandatory services, i.e.
computed — see note below; the flat per-room price is `269,00`.)
```sql
-- 1) rooms the hotel offers
-- PartnerZimmer ⋈ PartnerPreisKategorie on (IDAdresse_FS, IDArtPartnerPreisCode_FS)
SELECT pz.IDPartnerZimmer AS RoomId, za.Code AS RoomCode, za.Bezeichnung AS RoomLabel,
za.PaxMin, za.PaxMax, pz.IDArtPartnerPreisCode_FS AS PriceCode
FROM dbo.PartnerZimmer pz
JOIN dbo.Zimmerart za ON za.IDZimmerart = pz.IDZimmerart_FS
WHERE pz.IDAdresse_FS = @HotelAddressId;
-- 2) which season tafel covers the arrival date
SELECT IDPartnerSaisonTafel_FS FROM dbo.PartnerSaisonzeit
WHERE IDAdresse_FS = @HotelAddressId
AND TerminVon <= @Anreise AND TerminBis >= @Anreise
AND EK_VK = 'VK'; -- sale side
-- 3) price for the room's category × that season × nights
SELECT pp.Preis AS RoomPrice, pp.Naechte, pp.Verlaengerung
FROM dbo.PartnerPreisKategorie pk
JOIN dbo.PartnerPreis pp ON pp.IDPartnerPreisKategorie_FS = pk.IDPartnerPreisKategorie
WHERE pk.IDAdresse_FS = @HotelAddressId
AND pk.IDArtPartnerPreisCode_FS = @PriceCode -- from step 1 (room → category)
AND pk.IDArtVerpflegung_FS = @BoardId -- ReisePartner.IDArtVerpflegung_FS
AND pk.EK_VK = 'VK'
AND pp.IDPartnerSaisonTafel_FS = @SaisonTafelId -- from step 2
AND pp.Naechte = @Nights; -- nights of the stay
```
> `EK_VK` distinguishes purchase (`EK`) from sale (`VK`) — always filter `VK` for the
> customer price. `PartnerZimmer.PreisJeZimmer` flags per-room vs. per-person pricing.
```sql
-- availability (XML 'verfuegbar') — VALIDATED: = PartnerKontingent.Anzahl for the
-- (hotel, room-type, date-span). IDZimmer_FS = the XML idbuspro_zimmer (a Zimmerart).
-- Sample: IDAdresse_FS=224923, IDZimmer_FS=1413, 2026-12-11 → Anzahl=25 = XML verfuegbar.
SELECT IDZimmer_FS, Anzahl, BuchungsStatus FROM dbo.PartnerKontingent
WHERE IDAdresse_FS = @HotelAddressId
AND IDZimmer_FS = @ZimmerartId -- the room type (XML idbuspro_zimmer)
AND TerminVon <= @Abreise AND TerminBis >= @Anreise;
```
> `verfuegbar` is a **flat column** (`PartnerKontingent.Anzahl`) — reproducible by SQL.
> Gate it with `BuchungsStatus` (`''` bookable, `S` stop, `A` on-request).
> `<abpreis>` (the headline "from" price) is still **computed** (cheapest valid room +
> mandatory services) — recompute in the proxy from the per-room prices above.
### B4. Selection tags + groups — **confirmed**
```sql
SELECT gs.Selektionsgruppe AS GroupLabel, gs.Position AS GroupPos,
s.IDSelektionsstamm AS SelectionId, s.Selektion AS SelectionLabel
FROM dbo.ReiseSelektionen rs
JOIN dbo.Selektionsstamm s ON s.IDSelektionsstamm = rs.IDSelektionsstamm_FS
JOIN dbo.Selektionsgruppe sg ON sg.IDSelektionsstamm_FS = s.IDSelektionsstamm
JOIN dbo.Selektionsgruppenstamm gs ON gs.IDSelektionsgruppenstamm = sg.IDSelektionsgruppenstamm_FS
WHERE rs.IDReise_FS = @DateId
ORDER BY gs.Position, sg.Position;
```
---
## Status / open decisions
1. ~~**Status codes** (A1)~~ — ✅ confirmed: `Reise.Status` `''` = bookable, `S` = Storno
(cancelled, `StornoDatum` set), `L` = template/future master (exclude both).
2. ~~**F/A/O/S column**~~ — ✅ codes in `dbo.ArtBuchungsStatus` (F/O/A/W/S/U); the
working hotel-availability column is `PartnerKontingent.BuchungsStatus` (`''`/`S`/`A`).
3. ~~**Hotel link**~~ — ✅ XML `<hotel idbuspro>` = `Adresse.IDAdresse`; departure→hotel
via `PartnerProdukt(IDProdukt_FS, IDAdresse_FS)` at product level (not `ReisePartner`).
4. ~~**Partner room pricing**~~ — ✅ fully validated end-to-end: room `1413` (MBZ) →
code `38` → category `9478` → season `15420` × `Naechte=2` × `VK` × schema `1` =
**`269,00`**. (XML `abpreis 289,00` = room + mandatory services = computed.)
5. ~~**`verfuegbar`**~~ — ✅ flat: `PartnerKontingent.Anzahl` for `(IDAdresse_FS, IDZimmer_FS,
date-span)`; `1413 / 2026-12-11 → 25`. `<abpreis>` remains computed (recompute in proxy).
6. ~~**Minimum-services filter**~~ — ✅ defined: "included services" =
(A2a) `Leistung`s with sales price `0.00` (schema 1) **+** (A2b) selection items from
the hardcoded "Leistungen" `Selektionsgruppenstamm` ids, merged into one plain list.