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_boundtag). Every provider is asked and the lowest answer wins, unlike the placement seam where the first applicable provider decides.NULLmeans "no opinion" and is not0. BookingManager::availability()andavailabilityMap()fold those bounds into the existing quota and slot-cap arithmetic, soavailable = min(quota left, slot cap left, every provider bound).yoyaku_placementregistersVenueAvailabilityBound: a category is capped by the free places of the grades it prices, which isopen 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".SlotOverviewFormtakes its "free" figure fromavailabilityMap()instead ofcapacity - 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).