306 lines
16 KiB
Markdown
306 lines
16 KiB
Markdown
# 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` (2029–2035) 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.
|