Issue #3613137: Category free must be the min of the quota remaining and the free seats in its placement grade(s)

Change

  • New core seam AvailabilityBoundProviderInterface (yoyaku.availability_bound tag). Every provider is asked and the lowest answer wins, unlike the placement seam where the first applicable provider decides. NULL means "no opinion" and is not 0.
  • BookingManager::availability() and availabilityMap() fold those bounds into the existing quota and slot-cap arithmetic, so available = min(quota left, slot cap left, every provider bound).
  • yoyaku_placement registers VenueAvailabilityBound: a category is capped by the free places of the grades it prices, which is open places - places taken on that slot, whichever category took them. So two categories pricing one grade stop each promising the whole grade, and an uncapped category on a placed slot reads the real number of places instead of "unlimited".
  • SlotOverviewForm takes its "free" figure from availabilityMap() instead of capacity - taken, keeping its own aggregate only for the confirmed/completed/held/unused breakdown. It was printing a number the booking flow would refuse.

Performance

This is on the hot path (a calendar reads a whole page of slots), so nothing loads places:

  • PlaceAvailability::openPlaceCountsByGrade() counts in SQL, grouped by grade, memoized per venue and configuration for the request.
  • PlaceAvailability::takenPlaceCountsByGrade() takes every slot of the page in one grouped join.
  • bounds() receives the tiers the caller already loaded, so it never re-queries them.
  • A slot with no venue is dropped before any query runs, so an unplaced calendar costs nothing extra.
  • The "does this venue place?" gate is not called here: an empty count answers it, which removes a third query.

Net cost for a page: one open-count read per distinct (venue, configuration) plus one taken-count read. VenueAvailabilityBoundTest::testTheMapCostsFixedPlaceQueries asserts exactly that, so a future N+1 fails the build. Measured on a 1422-place venue: the map for a page took 20 ms with 3 place queries across two resources.

Pooled sections

The provider is named for the venue rather than the grade because a pooled section (free seating or standing) will contribute its remaining pool here rather than its places, and a venue may then bound a category while holding no place at all. The two spots that assume places are marked, and venueOffersPlacement() documents the flip that belongs with that feature.

Testing

New VenueAvailabilityBoundTest: two categories sharing a grade read that grade's free places and both drop when one place is booked; a lower quota still wins; an uncapped category is bounded by the places; a slot with no venue keeps its quota; and the query-count guard above. VenueScheduleTest gained the place schemas it was missing, since availability now reads them as it does on a site.

Green: yoyaku core kernel (109 tests), yoyaku_placement (32), yoyaku_ui (8), yoyaku_calendar (32). phpcs and phpcbf clean at --warning-severity=1, cspell clean. No fr.po change: no new translatable string (the overview's "free" label is unchanged, and an exception message is not translated).

Merge request reports

Loading