Issue #3616323: Offer the part of the house beside the price, not only on the plan

Offers the part of the house on the booking page, where until now it could only be chosen by clicking an outline on the plan.

The seam

A $wanted entry, and a request handed to RequestBooker::holdMany(), may carry 'area' => ['section' => int, 'subsection' => int|null]. It is a preference on the request, not a property of the area, so asking for the balcony from a control is the same act as clicking its zone, and it is useful whether the area is placed or pooled.

  • VenuePlacementProvider::place() partitions the party by confinement and seats each half from the one candidate read it already made, filtered rather than fetched again. testConfinementCostsNoExtraRead pins that asking for an area costs no query.
  • Confined parties are served before unconfined ones, or a booker who asked for the balcony could be refused because a booker who asked for nowhere in particular emptied it.
  • A named area that cannot take the party refuses and names itself. Nobody is seated elsewhere; nothing falls back to Auto.
  • consolidate() is confined too, on any resource that offers the choice at all, so closing a hole cannot move a party out of the area it chose. Nothing new is stored on a booking to know this: the question is whether areas mean anything to bookers here, and the resource answers it.

The offer

AreaOffers answers which areas hold available places of the grades an offer covers, and what each has left, so the plan and the booking page cannot disagree. An area that is full stays listed at nothing available: withRoom() is what a control offers, while a choice made a minute ago stays resolvable so it can be refused by name instead of quietly becoming "anywhere".

Grouped by the grades an offer covers rather than by tariff. One control per grade set keeps a party together, where one per card would let the full price go to one area and the youth price to another. A tariff pricing two grades is one offer in one group rather than one target with two quantity fields.

Configuration

One field rule, yoyaku_resource.area_choice, read through FieldRuleResolver::effective(), so a resource, its type, the tenant or the site may each answer it:

Answer What a control offers
never Nothing. Every unit is placed wherever the strategy puts it.
sections One option per section, whatever it is divided into.
subsections A divided section offers its parts instead of itself.
both The section, and each of its parts under it.

sections is the shipped site default: a short list, always meaningful, and a hall divided into forty rows does not open by offering a booker forty of them. The grain decides what a control lists and nothing else, since both grains are valid requests either way.

Fewer than two areas with room is no choice, so no control is shown. That is a decision about the control alone, kept apart from the map above it: an area that fills up can leave one area on offer, and the answer already posted goes on resolving after the control has gone.

Deviation from the issue summary

The summary says the options come from "the same area list the picker reads". The map splits a settled per-configuration half from a moving per-slot half across three cache layers, and one mixed list would break that split, so both surfaces derive from the same PlaceAvailability data layer instead. The summary needs rewording before merge.

The summary also describes the page as a flat list of cards; it has grouped tariffs into class panels since #3615148. Those panels are untouched: the selects sit in a group of their own above the offers.

Drive-by fixes, both found while reading the code this touches

  • Two tariffs standing in one pooled area were told their places were "spread around the venue", and a party of two tariffs could take one pooled unit twice (refused at the hold).
  • The shortfall refusal was an untranslated English sprintf reaching bookers. Now translatable; two existing expectations updated.

Verification

  • AreaChoiceTest (11) and SlotBookingPlacementTest (+4) green on sqlite and mysql, plus SeatTogetherTest, PooledSectionProviderTest, ApiPlacementTest, OrphanedPlacesTest, SeatWithoutOrphansTest, SectionPoolBookableTest, PlaceHoldCostTest, AvailablePlaceReadCountTest, PoolAvailabilityBoundTest, PlaceOrphansTest, the calendar kernel classes and the core hold and field-rule classes.
  • The seat-collision guard was seen to fail against the unfixed code (This place is already taken for this slot.). My first version of it was vacuous, because two parties confined to different areas read disjoint lists and cannot collide however it is written.
  • phpcs clean over the module root, phpstan clean, eslint 0 errors, cspell clean.
  • docs/placement.md, docs/calendar.md and 15 French strings in the same commit. potx reports 0 missing for this change; the 12 still missing are pre-existing gaps from other issues.

Branched off ec856d85. #3616290 (!221 (merged)) landed while this was being built and also touches sections and areas, so this will want a rebase if that merges first.

Merge request reports

Loading
Loading