Problem/Motivation
Every list of tariffs in yoyaku is narrowed to the front being served: what names no channel is offered everywhere, and what names channels is offered only where one of them is being served. BookingChannelFilter holds that rule so it is not spelled out per call site, and the slot booking page gets it because it reads BookingManager::availabilityMap(), which applies the filter to the query behind it.
The plan does not. VenueMapBuilder::slotTariffs() runs a query of its own over the resource's tariffs with no filter, and it is what fills both halves of the picker's payload: the tariffs a seat may be booked at, and the tariffs that may book an area offered by quantity.
So a tariff a hall holds back from one front is drawn on that front's plan, with its label and its price beside it, and a booker who takes it is refused at the hold, where RequestBooker::assertOnOffer() applies the rule the display skipped. Nothing overbooks and nothing is charged wrongly, which is why this is not critical. What the booker gets is a dead end they cannot see the reason for, and what the operator gets is one front's terms published on another's plan.
Where it comes from
BookingManager::tariffsByResource() carries a comment saying it is the one read the availability map is built from, and so the place where the whole engine and everything reading through it stops seeing a tariff held back from this front. That is true of the core availability map. The place map is a second reader with a query of its own, so the comment reads as coverage it does not have.
The plan cannot simply drop what it stops offering, because it also draws what the visitor already holds. renderSummary() looks a seat's tariff up in that same list and falls back to the list's first entry when it finds none, so a held tariff missing from it leaves no option selected, the browser shows the first, and the total prices that seat at the first tariff of its grade rather than the one it is held at. That is the failure the bookedAt map was added to prevent.
The booking page has the same gap from the other direction. It builds its offers from the filtered list alone and passes the held quantities only as prefill, so a line held at a tariff the front no longer offers gets no field and disappears from the page. Reaching it needs no crossing of fronts: an operator restricting a tariff while somebody holds it is enough. Nothing is refused at checkout, since assertOnOffer() is called on the booking path and neither the cart nor the order re-checks, so the line completes. This is what a booker is shown, not what the engine allows.
What shipped
VenueMapBuilder::slotTariffs() asks BookingChannelFilter rather than spelling the rule again. It is the one read #3616821 extracted, so the tariffs a seat may be booked at, the tariffs that may book an area offered by quantity, and the per-order allowances are all narrowed by the one edit; the allowances had been publishing a limit for a tariff nobody on that front could book.
What a visitor already holds is not narrowed, and does not belong in that read. The settled half is shared, permanent and per front, so a tariff kept there because this visitor holds it would be handed to the next visitor on the same front. So that half stays strictly what the front offers, and the offers a basket names that the front no longer does travel in the visitor's own half, which is built per request and never kept. mine() carries them, and that half has a seam of its own, hook_yoyaku_placement_map_basket_alter(), which yoyaku_payment prices and yoyaku_booking_fee words, the same pair in the same order as on the settled half.
In the browser such an offer is put back beside the seats and the areas holding it and nowhere else, marked withdrawn: no seat may be moved onto it, an area's stepper goes no higher than what is already held, and the row says the offer has been withdrawn.
The booking page had the same gap from the other direction and is fixed with it. It built its fields from the filtered list and passed the held quantities only as prefill, so a line held when a tariff was withdrawn got no field at all: still in the basket, still on the cart, gone from the page it is managed on. It joins those targets in per session now.
The comment on BookingManager::tariffsByResource() claimed to be the read the venue map is built from, which it never was. Corrected, with docs/booking-channels.md and docs/placement.md.
Coverage
VenueMapChannelOffersTest, SlotBookingChannelTest and FeeBasketOfferTest were each seen to fail against the unfixed branch before the fix, on the assertions that matter.
WithdrawnOfferPickerTest drives a browser, and earned its place: the payload carried the offer correctly while every use of it in the panel was wrong, because the half that folds it onto the map dropped it at all four points that half is built. Every server-side test passed throughout. A seat held at a withdrawn tariff was drawn at another tariff's price, and nothing but a browser could see it.
All four run their cases as scenarios on one install, following #3616973: Run the test suite as two jobs, and stop the tests paying for installs nothing uses.
Remaining tasks
- Review MR !248.
AI-Generated: Yes (Claude Code wrote the code and the tests on the merge request, and drafted this issue summary. I reviewed both before posting.)
Issue fork yoyaku-3617045
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #4
mably commentedComment #5
mably commentedComment #7
mably commented