Issue #3615214: Split the seating strategy: the row geometry and the rate matching are not one plugin's business
A pure refactor: no behavior changes, and every existing test passes untouched, which is the only thing that makes a refactor a refactor.
Together goes from 1455 lines to 985, and what leaves it is the two parts that were never a strategy's opinion:
PlaceGeometry(yoyaku_placement.place_geometry) holdsrows(),run(),runsOf(),areas(),sections(),rowOrder(),apart(),fragments()andisolation(). Group these places into rows; find a stretch this long; find every window of that length; find the unbroken runs a party is sitting in; rank an area's rows; measure how far two places are apart. It takes places and gives back places.SeatingShareOut(yoyaku_placement.seating_share_out) holdsshareOut()and its helpers, the bipartite matcher included, pluswantedWithAnchors()andgradeIds(). It answers which of a run's places each tariff takes.
Both are stateless and read no storage, so they are plain services with no arguments, and the methods moved verbatim. Together now implements ContainerFactoryPluginInterface and injects both; the properties are protected and not readonly, because PluginBase carries DependencySerializationTrait and phpstan is right about that.
Deliberately not here: a base class or a trait for strategies. Two injectable collaborators may well be enough, and a base class designed against a single implementation is a guess about the second one. Neither collaborator gets an interface yet for the same reason.
Two things folded in, both named in the issue. The row map is now built once for the whole ladder instead of once per rung: four rungs read the free places grouped into rows and all four read the same list, so grouping it per rung walked the hall four times for one answer. The two rungs that fold the party's own places back in still build their own, because their list is genuinely a different one. And VenuePlacementProvider had a byte-identical private copy of gradeIds(); it now asks the share-out, so the two cannot drift.
New coverage, at the level these now sit. PlaceGeometryTest and SeatingShareOutTest assert them directly rather than through a whole hold, which is how most of this was covered before: a booking is a slow and indirect way to find out whether a row of six holds a run of seven. Between them they pin a row shorter than the run asked for, a gap no run spans, every window of one stretch, a place the venue numbers no row for, a place in a pool as a fragment of its own, the distance ladder, a full row still being ranked, tariffs pricing alike, a tariff pricing nothing in the run, an arrangement only the matching finds, and a run the tariffs cannot serve either wholly or partially. Both read their collaborator out of the container, so the service a second strategy would ask for is the thing under test.
Verified locally: SeatTogetherTest, VenuePlacementProviderTest, SlotBookingPlacementTest, PlaceBookingTest and AvailablePlaceReadCountTest, the last of these because none of this reads anything new.
docs/seating.md gains the two services under "Writing your own strategy", and the cost section says the row map is built once.