471 lines
23 KiB
Markdown
471 lines
23 KiB
Markdown
# `SelektionCRM` — how the XML is assembled, and how to debug a missing selection
|
||
|
||
> **Authority: [`docs/buspro-database-reference.md`](../buspro-database-reference.md).**
|
||
> Schema semantics and the falsified-theory record live there and win on any conflict.
|
||
> This file is retained as the **full investigation record** for the missing-selection /
|
||
> `aenderbar` problem, including the queries that closed each theory.
|
||
|
||
Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (live
|
||
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
|
||
the document that drives the portal's entire authorization model.
|
||
|
||
Request side: `ApiClient::getCrmAttributes()` (`src/BusProNet/ApiClient.php`) sends
|
||
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
|
||
Response side: `CrmAttributesResponseParser`
|
||
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
|
||
Symfony roles:
|
||
|
||
| `IDSelektionsstamm` | Role |
|
||
| --- | --- |
|
||
| `1070` | `ROLE_TEAMER` |
|
||
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) |
|
||
| `1293` | `ROLE_MANAGER` |
|
||
| `1477` | `ROLE_GROUPS_MANAGER` |
|
||
| `1478` | `ROLE_GROUPS_ADMIN` |
|
||
| `1483` | `ROLE_CUSTOMER_EXPERT` |
|
||
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
||
| nothing matched | `ROLE_CUSTOMER` |
|
||
|
||
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
|
||
one of these roles.
|
||
|
||
---
|
||
|
||
## The four tables
|
||
|
||
From `BusPronet_20260601-1517.csv` (columns) and `BusPronet_20260601-1518.csv` (FKs):
|
||
|
||
| Table | Columns | Role |
|
||
| --- | --- | --- |
|
||
| `dbo.Selektionsstamm` | `IDSelektionsstamm`, `Selektion`, `Status` nvarchar(2) null, `System` tinyint | the selection master — that's *all four* columns |
|
||
| `dbo.Selektionsgruppenstamm` | `IDSelektionsgruppenstamm`, `Selektionsgruppe`, `Status`, `System`, `Ausgrenzung`, `Position` | the group master |
|
||
| `dbo.Selektionsgruppe` | `IDSelektionsgruppenstamm_FS`, `IDSelektionsstamm_FS`, `Position` | **membership** — which selection sits in which group, and in what order |
|
||
| `dbo.Selektionen` | `IDAdresse_FS`, `IDSelektionsstamm_FS` | **assignment** — which address holds which selection |
|
||
|
||
`<crmaktionen>` comes from a parallel pair: `dbo.CRMAktion` (master: `Code`,
|
||
`Bezeichnung`, `Aktiv`, `Status`, `AktivDatumVon/Bis`, …) and `dbo.CRMZuweisung`
|
||
(assignment via `IDAdresse_FS` + `IDCRMAktion_FS`).
|
||
|
||
### XML → column mapping
|
||
|
||
| XML | Source |
|
||
| --- | --- |
|
||
| `<idadresse>` | `Adresse.IDAdresse` (the authenticated account) |
|
||
| `<idperson>` | `AdressePerson.IDAdressePerson` |
|
||
| `<selektionsgruppe id>` / `bezeichnung` | `Selektionsgruppenstamm.IDSelektionsgruppenstamm` / `.Selektionsgruppe` |
|
||
| `<selektion id>` / `bezeichnung` | `Selektionsstamm.IDSelektionsstamm` / `.Selektion` |
|
||
| `selektion@auswahl` | `EXISTS (Selektionen WHERE IDAdresse_FS = <account> AND IDSelektionsstamm_FS = <id>)` — confirmed |
|
||
| `selektion@aenderbar` | **computed, not stored** — no such column exists anywhere (see below) |
|
||
| group order | `Selektionsgruppenstamm.Position`, `<ohne Gruppe>` first |
|
||
| selection order within a group | `Selektionsgruppe.Position` |
|
||
| `<crmaktion …>` | `CRMAktion` + `CRMZuweisung` (`auswahl` = assignment exists) |
|
||
|
||
### `aenderbar` is not a stored flag
|
||
|
||
A case-insensitive search for `aenderbar` / `auswahl` across **every column name in the
|
||
database** (1023 tables, `ABOBewegungsdaten` … `ZustiegsPlanungPersonStelle`, so the
|
||
export is complete and untruncated) returns no hit on any `Selektion*` table. The only
|
||
`*Aenderbar*` column in the entire schema is `AdresseAgenturZusatz.ZahlungsartAenderbar`,
|
||
which is unrelated. And the three core tables have no spare bit to hide it in:
|
||
`Selektionsstamm` has exactly four columns, `Selektionsgruppe` three, `Selektionen` two.
|
||
|
||
So `aenderbar` is **derived at response time**. Don't go looking for the column again —
|
||
this is what Query D and Query E are for.
|
||
|
||
The `<crmaktionen>` half of the document hints at the intended semantics: `CRMAktion`
|
||
*does* carry real flags (`Aktiv`, `Anzeige`, `AktivDatumVon`/`Bis`), and in the reference
|
||
sample the long-expired "Deal Der Woche KW40-2018" is `aenderbar="False"` while the
|
||
standing "NOMAIL" opt-out is `"True"`. `aenderbar` reads as *"the customer may still
|
||
toggle this"* — a validity/whitelist question, not a static per-row property.
|
||
|
||
Two orderings worth noting, because they rule out the obvious alternatives:
|
||
|
||
- Inside `TEAM` the ids run `… 1374, 1375, 1376, 1373, 1377 …` — neither id order nor
|
||
label order. That is `Selektionsgruppe.Position`.
|
||
- Within `Interessen` the labels *are* alphabetical, which is consistent with
|
||
`Position` having been maintained alphabetically there. Don't read it as a
|
||
different rule.
|
||
|
||
### The decisive observation: the catalog is group-driven
|
||
|
||
Every `Hausleitung *` row is emitted with `auswahl="False"` for an account that holds
|
||
none of them. So the endpoint does **not** list "the selections this address has" — it
|
||
lists **the whole published catalog** and then flags which entries the address holds.
|
||
|
||
`<selektionsgruppe id="0" bezeichnung="<ohne Gruppe>">` is the bucket for
|
||
selections with no (or a `0`) `Selektionsgruppe` membership row.
|
||
|
||
The practical consequence: **inserting a row into `dbo.Selektionen` can never make a
|
||
selection appear.** It can only flip `auswahl` from `False` to `True` on an entry that
|
||
the catalog already emits. A selection that is absent from the XML is absent from the
|
||
*catalog*, and the assignment is irrelevant until that is fixed.
|
||
|
||
---
|
||
|
||
## Query A — reconstruct the response
|
||
|
||
Run this for the account in question and diff its rows against the actual XML. A clean
|
||
match proves the reconstruction above; any mismatch belongs in the caveats section.
|
||
|
||
```sql
|
||
DECLARE @AddressId bigint = 141747;
|
||
|
||
SELECT COALESCE(sg.IDSelektionsgruppenstamm, 0) AS groupId,
|
||
COALESCE(sg.Selektionsgruppe, '<ohne Gruppe>') AS groupLabel,
|
||
s.IDSelektionsstamm AS selectionId,
|
||
s.Selektion AS selectionLabel,
|
||
CASE WHEN s.System = 0 THEN 'True' ELSE 'False' END AS aenderbar,
|
||
CASE WHEN sel.IDAdresse_FS IS NULL THEN 'False' ELSE 'True' END AS auswahl
|
||
FROM dbo.Selektionsstamm s
|
||
LEFT JOIN dbo.Selektionsgruppe sgm
|
||
ON sgm.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||
LEFT JOIN dbo.Selektionsgruppenstamm sg
|
||
ON sg.IDSelektionsgruppenstamm = sgm.IDSelektionsgruppenstamm_FS
|
||
LEFT JOIN dbo.Selektionen sel
|
||
ON sel.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||
AND sel.IDAdresse_FS = @AddressId
|
||
WHERE COALESCE(s.Status, '') <> 'L'
|
||
AND COALESCE(sg.Status, '') <> 'L'
|
||
ORDER BY COALESCE(sg.Position, -1), sgm.Position, s.Selektion;
|
||
```
|
||
|
||
(`Status = 'L'` is this database's soft-delete marker — see
|
||
`../bpn-connect-query-reference.md`.)
|
||
|
||
Note the join fans out: a selection can belong to **several** groups (confirmed —
|
||
`1292` is in both `10` and `91`), so this query emits one row per membership. The XML
|
||
shows a selection once per group it is published in.
|
||
|
||
`Selektionsgruppenstamm.Ausgrenzung` is *exclusion* semantics (mutually exclusive
|
||
group), not visibility — don't mistake it for a publish flag.
|
||
|
||
---
|
||
|
||
## Query B — diagnose a selection that never appears
|
||
|
||
Compares the suspect row against known-good neighbours: `1292`/`1293` (long-standing,
|
||
emitted, `aenderbar=False`) and `1462` (most recent one that *is* emitted,
|
||
`aenderbar=True`). Whatever column differs is the answer.
|
||
|
||
```sql
|
||
SELECT s.IDSelektionsstamm,
|
||
s.Selektion,
|
||
s.Status,
|
||
s.System,
|
||
sgm.IDSelektionsgruppenstamm_FS AS groupId,
|
||
sgm.Position AS positionInGroup,
|
||
(SELECT COUNT(*) FROM dbo.Selektionen x
|
||
WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||
AND x.IDAdresse_FS = 141747) AS assignedToAddress
|
||
FROM dbo.Selektionsstamm s
|
||
LEFT JOIN dbo.Selektionsgruppe sgm
|
||
ON sgm.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||
WHERE s.IDSelektionsstamm IN (1477, 1478, 1292, 1293, 1462)
|
||
ORDER BY s.IDSelektionsstamm;
|
||
```
|
||
|
||
### What Query B ruled out (address `141747`, 2026-08-04)
|
||
|
||
| id | Selektion | Status | System | groupId | posInGroup | assigned |
|
||
| --- | --- | --- | --- | --- | --- | --- |
|
||
| 1292 | Admin | *(blank)* | 1 | 10 | 33 | 1 |
|
||
| 1292 | Admin | *(blank)* | 1 | **91** | 1 | 1 |
|
||
| 1293 | Manager | *(blank)* | 1 | **92** | 1 | 1 |
|
||
| 1293 | Manager | *(blank)* | 1 | 10 | 34 | 1 |
|
||
| 1462 | Hausleitung AGR | *(blank)* | 1 | 10 | 74 | 0 |
|
||
| 1477 | Preisrechner | *(blank)* | 1 | 10 | 75 | 0 |
|
||
| 1478 | Preisrechner Admin | *(blank)* | 1 | 10 | 76 | 1 |
|
||
|
||
Three conclusions:
|
||
|
||
1. **The missing-membership theory is dead.** `1477`/`1478` *are* in group `10` (TEAM),
|
||
at positions `75`/`76`, and `1478` *is* assigned to the address. On every column
|
||
available in these three tables, `1478` is indistinguishable from `1462` — which the
|
||
XML does emit. Whatever excludes them lives **outside**
|
||
`Selektionsstamm` / `Selektionsgruppe` / `Selektionen`.
|
||
2. **`aenderbar` is not derived from `System`.** `1292` (`aenderbar="False"`) and `1462`
|
||
(`aenderbar="True"`) both have `System = 1`. `Selektionsstamm` has no other column,
|
||
so `aenderbar` comes from somewhere else entirely.
|
||
3. **A selection can belong to several groups, and some groups are not published.**
|
||
`1292` is in `10` + `91`, `1293` is in `10` + `92`; groups `91`/`92` appear nowhere in
|
||
the XML, which only emits `0`, `10`, `83`. So there is a **group-level publish
|
||
filter** — and, suggestively, the two rows with a second (unpublished) group
|
||
membership are exactly the two that come back `aenderbar="False"`.
|
||
|
||
---
|
||
|
||
## Query D — the agency whitelist (run this first)
|
||
|
||
`dbo.AgenturSelektionenKunde` is a bare two-column link table,
|
||
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
|
||
portal authenticates against BusPro as an **agency** account (`bpn_username` in
|
||
`ApiClient`), so a per-agency whitelist of which customer selections that agency may see
|
||
and/or edit is exactly the right shape. It is the only candidate found so far that could
|
||
explain the `1478` omission **and** `aenderbar` through one mechanism.
|
||
|
||
```sql
|
||
SELECT ask.IDAdresse_FS AS agencyAddressId,
|
||
a.SuchName AS agencyName,
|
||
ask.IDSelektionsstamm_FS AS selectionId,
|
||
s.Selektion AS selectionLabel
|
||
FROM dbo.AgenturSelektionenKunde ask
|
||
JOIN dbo.Selektionsstamm s ON s.IDSelektionsstamm = ask.IDSelektionsstamm_FS
|
||
LEFT JOIN dbo.Adresse a ON a.IDAdresse = ask.IDAdresse_FS
|
||
ORDER BY ask.IDAdresse_FS, s.Selektion;
|
||
|
||
-- same shape, same question:
|
||
SELECT * FROM dbo.PartnerSelektionenKunde;
|
||
```
|
||
|
||
Compare the row set for the portal's agency address against a freshly captured response:
|
||
|
||
| Result | Meaning |
|
||
| --- | --- |
|
||
| set == the 32 emitted entries | **this is the publish gate** — `1477`/`1478` just need rows here |
|
||
| set == the `aenderbar="True"` subset only | this is the `aenderbar` source |
|
||
| set is some third thing | note it in this doc and continue to C1 |
|
||
| table empty | both hypotheses dead — fall through to C1/C2/C3 |
|
||
|
||
**Result (2026-08-04): `dbo.AgenturSelektionenKunde` is empty.** The agency-whitelist
|
||
theory is dead. Do not re-run.
|
||
|
||
---
|
||
|
||
## Query E — group-count cross-check (the secondary `aenderbar` theory)
|
||
|
||
Query B hinted that the two `aenderbar="False"` rows were the two with a *second*,
|
||
unpublished group membership (`1292`→{10, 91}, `1293`→{10, 92}) while `aenderbar="True"`
|
||
`1462` sat in group `10` alone. Two data points is not a rule — this settles it in one run:
|
||
|
||
```sql
|
||
SELECT s.IDSelektionsstamm,
|
||
s.Selektion,
|
||
COUNT(*) AS groupCount,
|
||
STRING_AGG(CAST(sgm.IDSelektionsgruppenstamm_FS AS varchar(10)), ',') AS groupIds
|
||
FROM dbo.Selektionsstamm s
|
||
JOIN dbo.Selektionsgruppe sgm ON sgm.IDSelektionsstamm_FS = s.IDSelektionsstamm
|
||
WHERE s.IDSelektionsstamm IN (1064,1065,1066,1067,1068,1069,1070,1156,1292,1293,
|
||
1299,1304,1377,1459,1461,1462,1477,1478)
|
||
GROUP BY s.IDSelektionsstamm, s.Selektion
|
||
ORDER BY s.IDSelektionsstamm;
|
||
```
|
||
|
||
Read against the known `aenderbar` values from the reference response:
|
||
|
||
- `aenderbar="False"`: `1070`, `1156`, `1292`, `1293`, `1299`, `1304`, `1377`
|
||
- `aenderbar="True"`: `1064`–`1069`, `1459`, `1461`, `1462`
|
||
|
||
If every `False` row has `groupCount > 1` and every `True` row has `groupCount = 1`, the
|
||
theory holds. Note it still would not explain `1478` (group `10` only, yet omitted), so
|
||
Query D remains the priority.
|
||
|
||
**Result (2026-08-04): the theory is dead.** Group membership does not correlate with
|
||
`aenderbar` at all:
|
||
|
||
| id | Selektion | groupCount | groupIds | `aenderbar` |
|
||
| --- | --- | --- | --- | --- |
|
||
| 1064–1069 | *Interessen* | 1 | 83 | **True** |
|
||
| 1070 | E&P Teamer - allg. Merkmal | 1 | 10 | **False** |
|
||
| 1292 | Admin | 2 | 91, 10 | **False** |
|
||
| 1293 | Manager | 2 | 92, 10 | **False** |
|
||
| 1299 / 1304 / 1377 | Hausleitung SSL / DKS / KHH | 1 | 10 | **False** |
|
||
| 1459 / 1461 / 1462 | Hausleitung SVS / SHM / AGR | 1 | 10 | **True** |
|
||
| 1477 / 1478 | Preisrechner, Preisrechner Admin | 1 | 10 | *(not emitted)* |
|
||
|
||
`1299`/`1304`/`1377` and `1459`/`1461`/`1462` have **identical** group membership,
|
||
identical `Status`, identical `System` — and opposite `aenderbar`. The `1292`/`1293`
|
||
double membership was a coincidence.
|
||
|
||
Confirmed as a side effect: `1156` is absent from this result because the inner join
|
||
found no `Selektionsgruppe` row for it. `<ohne Gruppe>` really does mean "no membership
|
||
row", as modelled at the top of this document.
|
||
|
||
---
|
||
|
||
## Query F — exhaust every remaining link to `Selektionsstamm`
|
||
|
||
After D and E, the schema export lists exactly these tables with an FK into
|
||
`Selektionsstamm`. This counts all of them at once for the rows whose behaviour differs,
|
||
which is the last thing the database can tell us:
|
||
|
||
```sql
|
||
SELECT s.IDSelektionsstamm, s.Selektion,
|
||
(SELECT COUNT(*) FROM dbo.Selektionen x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS addressAssignments,
|
||
(SELECT COUNT(*) FROM dbo.PartnerSelektionenKunde x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS partnerLinks,
|
||
(SELECT COUNT(*) FROM dbo.LeistungSelektionenKunde x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS serviceLinks,
|
||
(SELECT COUNT(*) FROM dbo.ReiseleiterSelektionen x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS guideLinks,
|
||
(SELECT COUNT(*) FROM dbo.ReiseSelektionen x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS travelLinks,
|
||
(SELECT COUNT(*) FROM dbo.ReisezielSelektionsstamm x WHERE x.IDSelektionsstamm_FS = s.IDSelektionsstamm) AS destinationLinks
|
||
FROM dbo.Selektionsstamm s
|
||
WHERE s.IDSelektionsstamm IN (1070,1156,1292,1293,1299,1304,1377,1459,1461,1462,1477,1478,1064)
|
||
ORDER BY s.IDSelektionsstamm;
|
||
```
|
||
|
||
The decisive comparison is `1377` (`aenderbar="False"`) vs `1462` (`"True"`) vs `1478`
|
||
(omitted). If all three rows are identical here too, then **no per-selection property in
|
||
this database distinguishes them**, and the rule is not in the schema at all — go to
|
||
"Where this leaves the investigation" below.
|
||
|
||
**Result (2026-08-04): all link counts are zero** for every row queried, except
|
||
`travelLinks` on `1070` (11) and `1156` (3) via `ReiseSelektionen` — neither of which
|
||
correlates with `aenderbar` or with being emitted. Assignment counts don't explain
|
||
anything either:
|
||
|
||
| id | assignments | emitted? | `aenderbar` |
|
||
| --- | --- | --- | --- |
|
||
| 1377 Hausleitung KHH | **0** | yes | False |
|
||
| 1462 Hausleitung AGR | 1 | yes | True |
|
||
| 1478 Preisrechner Admin | **3** | **no** | — |
|
||
|
||
`1377` is published with zero assignments; `1478` has three and is not published. This
|
||
exhausts every FK path into `Selektionsstamm` in the schema. **Stop querying the
|
||
database** — the answer is not in it.
|
||
|
||
---
|
||
|
||
## Query C — locate the publish gate
|
||
|
||
Point (1) means some table or config outside the three core tables decides what gets
|
||
emitted. Run these in order; the first hit explains everything.
|
||
|
||
**C1 — what distinguishes a published group from an unpublished one.** Groups `0`, `10`,
|
||
`83` are emitted; `91`, `92` are not:
|
||
|
||
```sql
|
||
SELECT IDSelektionsgruppenstamm, Selektionsgruppe, Status, System, Ausgrenzung, Position
|
||
FROM dbo.Selektionsgruppenstamm
|
||
ORDER BY Position;
|
||
```
|
||
|
||
If exactly the emitted groups share a `Status`/`System`/`Position` value that `91`/`92`
|
||
lack, that's the group gate. It still won't explain `1478` (same group as `1462`), but
|
||
it pins down half the rule — and tells you whether `aenderbar` tracks membership in one
|
||
of these system groups.
|
||
|
||
**C2 — hunt for a published-selection whitelist in the config stores.** This is the
|
||
leading theory for `1478`: a config value enumerating exposed selection ids that was
|
||
maintained up to `1462` and never extended. This database does store id lists in
|
||
delimited string columns (`CRMAktion.IDsAgenturEingrenzung`,
|
||
`ImportAdressen.ZuweisungSelektionIDs`, `AdresseHistorie.SelektionsIDs`), so the pattern
|
||
exists. Search for a known-published id:
|
||
|
||
```sql
|
||
SELECT * FROM dbo.Einstellung
|
||
WHERE Wert LIKE '%1462%' OR Wert LIKE '%1292%'
|
||
OR Bezeichnung LIKE '%elektion%' OR Beschreibung LIKE '%elektion%';
|
||
|
||
SELECT * FROM dbo.ProgrammEinstellung
|
||
WHERE Wert LIKE '%1462%' OR Wert LIKE '%1292%' OR Variable LIKE '%elektion%';
|
||
|
||
SELECT * FROM dbo.ConnectEinstellung
|
||
WHERE WertString LIKE '%1462%' OR WertString LIKE '%1292%';
|
||
```
|
||
|
||
**C3 — brute-force the whole schema** if C1/C2 come up empty. Find every wide text
|
||
column that mentions a published selection id:
|
||
|
||
```sql
|
||
DECLARE @needle nvarchar(20) = '1462';
|
||
DECLARE @sql nvarchar(max) = '';
|
||
|
||
SELECT @sql = @sql + 'SELECT ''' + QUOTENAME(t.name) + ''' AS tbl, ''' + QUOTENAME(c.name)
|
||
+ ''' AS col, COUNT(*) AS hits FROM dbo.' + QUOTENAME(t.name)
|
||
+ ' WHERE CAST(' + QUOTENAME(c.name) + ' AS nvarchar(max)) LIKE ''%'
|
||
+ @needle + '%'' HAVING COUNT(*) > 0;' + CHAR(10)
|
||
FROM sys.tables t
|
||
JOIN sys.columns c ON c.object_id = t.object_id
|
||
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
||
WHERE ty.name IN ('nvarchar', 'varchar', 'nchar', 'char')
|
||
AND (c.max_length = -1 OR c.max_length >= 100)
|
||
AND t.name NOT LIKE 'Selektion%';
|
||
|
||
EXEC sp_executesql @sql; -- read-only; slow, run off-peak
|
||
```
|
||
|
||
**C4 — rule out staleness/caching first, it's free.** `1478` is structurally identical
|
||
to an emitted row, which is also what a cached catalog looks like. Re-fetch the XML now
|
||
and confirm the sample postdates the insert:
|
||
|
||
```php
|
||
// bin/console or a scratch script
|
||
$client->getCrmAttributes($email, $password);
|
||
```
|
||
|
||
If BusPro caches the catalog in-process, an application-pool/service restart on the
|
||
BusProNet host is the fix, and no schema change is needed.
|
||
|
||
---
|
||
|
||
## Portal side: a correct XML is not enough
|
||
|
||
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User`
|
||
entity at login (`setRoles($crmAttributes->roles)` /
|
||
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So
|
||
after fixing BusPro the affected user must **log out and log in again** before
|
||
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set.
|
||
|
||
---
|
||
|
||
## Where this leaves the investigation
|
||
|
||
**Conclusion as of 2026-08-04, after Queries A–F: the schema search is exhausted.**
|
||
|
||
- `1478` and `1462` are identical on every column and every link the schema exposes —
|
||
same blank `Status`, same `System = 1`, same single membership in group `10`, adjacent
|
||
positions (76 vs 74), zero links in all five side tables. `1462` is emitted, `1478` is
|
||
not.
|
||
- `1377` (`aenderbar="False"`) and `1462` (`"True"`) are identical by the same measure.
|
||
Opposite flag.
|
||
- Assignment count is not it either: `1377` is published with zero assignments, `1478`
|
||
has three and is not published.
|
||
|
||
Rows that are indistinguishable in the database behave differently in the response. Every
|
||
column of the three core tables, every FK path into `Selektionsstamm`, and a
|
||
whole-schema column-name search have now been checked. So **the publish set and the
|
||
`aenderbar` flag are not derived from this database** — they live in the BusProNet
|
||
service layer: its own configuration, or an in-process cache of the catalog.
|
||
|
||
Only C2/C3 (a text search of the config stores, then of every wide text column) remain,
|
||
and they are long shots. Prefer the escalation path below.
|
||
|
||
Escalation path, cheapest first:
|
||
|
||
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
|
||
`xml-sync` cache tag covers the XML *export* sync, and `ApiClient::getCrmAttributes()`
|
||
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
|
||
from a response captured after the insert, the staleness is on the BusProNet host and
|
||
an application-pool/service restart there is the test.
|
||
2. **The UI experiment.** Create a throwaway selection in the TEAM group *through the
|
||
BusPro UI*, then diff its rows against `1478` across `Selektionsstamm` /
|
||
`Selektionsgruppe` / `Selektionen`. If it appears in the XML, the diff names exactly
|
||
what a manual `INSERT` misses. If it does not, the gate is provably config/cache and
|
||
not per-row data.
|
||
3. **Ask the vendor.** "What determines which `Selektionsstamm` entries `SelektionCRM`
|
||
publishes, given group membership is not sufficient — and what drives `aenderbar`?"
|
||
Both are one-sentence answers for them and may not be discoverable from the schema at
|
||
all. Worth bundling into the open BPN thread.
|
||
|
||
## Caveats
|
||
|
||
- **`aenderbar` has no known source.** No such column exists in any of the 1023 tables;
|
||
`Selektionsstamm.System` (Query B), the agency whitelist (Query D) and group membership
|
||
(Query E) are all falsified. Treat it as unmodellable in a `bpn-connect` endpoint until
|
||
the vendor explains it — if `bpn-connect` must serve this response, the safe move is to
|
||
keep `SelektionCRM` on the socket API rather than guess, exactly as
|
||
`live-api-queries.md` recommends for `getMutableData()`.
|
||
- Groups `91` and `92` are known to exist and are never emitted; what they are is still
|
||
unexplained (Query C1). Record the answer here when you have it.
|
||
- **The publish gate is not identified.** Group membership is necessary but not
|
||
sufficient: `1478` sits in the published TEAM group with an assignment and is still
|
||
omitted. Query C is the open investigation.
|
||
- Consequently Query A **over-reports**: it will return `1477`/`1478` (and anything in
|
||
groups `91`/`92`) that the real XML omits. It reconstructs the shape of the response,
|
||
not yet its exact membership.
|
||
- `dbo.Selektionen` has no status column and no exported primary key, so a duplicate
|
||
assignment row may well be insertable. Check before inserting.
|
||
- The schema exports carry no PK/index catalog (noted in
|
||
`../bpn-connect-query-reference.md`); none of the queries here are
|
||
performance-sensitive, but a `bpn-connect` endpoint built on them would want indexes
|
||
on `Selektionen.IDAdresse_FS` and `Selektionsgruppe.IDSelektionsstamm_FS` confirmed.
|
||
- `Adresse` vs. `AdressePerson`: assignments hang off `IDAdresse`, not `IDPerson`.
|