Issue #3614386: Price a production from a reusable tariff preset instead of retyping its tariffs
A tariff preset holds a priced grid once and stamps it onto a production. A hall with four seat grades and three audiences sells twelve tariffs, and a season of forty productions meant typing those twelve rows forty times.
Applying copies. Afterwards a production's tariffs are its own and nothing reads a preset at sale time, so one edit can never reprice what is already on sale. That is not a style choice: the effective price is derived live for orders that already exist, in the order summary, the per-booking column, the amount the gateway is asked to charge, and the figure in notification emails.
What lands
yoyaku_tariff_presetandyoyaku_tariff, composed by the same three modules as a production's tariff, which is what makes applying a field-for-field copy: whatever a submodule adds to one it adds to the other.TariffPresetApplier, which plans and applies. It asks a taggedyoyaku.tariff_applicabilityseam whether a tariff belongs on a resource, the way availability bound providers narrow capacity, because the engine knows nothing about venues.yoyaku_placementanswers it. A preset names its venues; a cell prices grades of those venues only; one cell may not span two halls; and a preset holding any graded tariff must name a venue, which is the direction that would otherwise fail open.- Both grade fields are now scoped by a selection handler and offered as checkboxes, so selection and the write gate agree rather than a bad pick being caught only afterwards.
yoyaku_paymentcarries a requiredpriceon a preset's tariff, because a production's tariff requires one and a preset leaving a cell unpriced would stamp out an invalid tariff. Zero is a valid answer.- An apply form: pick the presets, then confirm. One fieldset per preset, a table of tariffs to create with a header checkbox that takes the whole preset, a separate table of tariffs already there where selecting a row overwrites its price and quota, refused tariffs listed with the reason as text, and the count of sessions already on sale.
- The tariff class becomes tenant-level and gains an admin UI. Scoped per resource it would have re-fragmented the vocabulary a preset exists to share.
Adjacent fixes carried here
Three, each one surfaced by this work:
- A production's tariff could name another hall's grade, silently. It now offers only its own venue's grades, matching the preset's cell.
docs/concepts.mdstill called a tariff a "rate" in five places and its ER diagram still showed one tariff scope. It now shows all three.- The slot generator's capacity description named
default_quota, a field the earlier rename removed.
Verification
Three new kernel classes: TariffPresetApplierTest, SingleTariffPerCellTest and PresetVenueBindingTest. The applier tests were checked against three separate mutations of the applier (dropping the same-key dedupe, always overwriting, ignoring the selection) and each turned them red, so they pin the behavior rather than ratifying the code.
Asserted directly: a plain list of adult / child / vip with no class and no grade validates and applies, since that is the case a cell-derived key would have collapsed; a preset naming no venue may not price grades; a preset built for another hall is not offered at all, while one that names the venue and prices nothing for it is offered as empty; and both selection handlers are asserted through the plugin manager rather than the rendered form, so they hold for a programmatic write.
docs/tariff-presets.md, the concepts page, and translations/yoyaku.fr.po (60 new strings) are in the same commit. phpcs with --warning-severity=1, phpstan with the Drupal ruleset, and cspell are clean.
No update hook: pre-1.0, reinstall is the upgrade path.