Issue #3617964: Engine refusals reach the booker untranslated, naming entity ids

A refusal now carries two sentences instead of one.

getMessage() keeps the entity ids and the states, in English, for the log and for whoever reads a stack trace. What a booker may read is a separate translatable sentence, attached by BookingException::refusal() and read back by getTranslatedMessage(), named after core's own UnmetDependenciesException::getTranslatedMessage(), which splits the same two sentences the same way. A surface asks the new yoyaku.booker_refusal service, which returns the sentence when there is one and otherwise logs the technical message and answers with a generic one, so a message written for a developer can no longer reach a page by accident.

No new exception classes: the subclasses already carry control-flow meaning (the retry-without-confinement loop keys on CapacityExceededException, the seat map tells PolicyRefusedException from the rest), and the sentence is data on the base class rather than a new axis.

What the sweep found. The issue names two messages; there were nine, all reachable by a booker through the four places that pass a caught message to a page:

  • the quota or capacity that will not take the whole ask (BookingManager::assertLimit())
  • a resource, and a tariff, not on offer on this booking channel (RequestBooker::assertOnOffer())
  • a slot, and a tariff, deleted while a hold waited for its lock (BookingManager::reloadForCheck())
  • a lock anchor that was missing and could not be written (LockAnchors)
  • seats already taken when the hold went to claim them (SlotPlaces)
  • a place a booking save cannot hold (SlotPlaceHooks)
  • a hold that had lapsed before it could be confirmed, and an order no longer open for editing

Four of the sentences reuse msgids the module already ships, so they arrive translated. The orphan "The slot is no longer available." leaves the French catalogue: nothing called t() on it, so it was translated and unreachable.

Performance. assertLimit() runs under the slot lock on every line of every group and was formatting its label eagerly, on the passes that fit as well as the one that refuses. It now takes two ints and builds the label only when it throws, which takes a sprintf out of the hot path. The refusal sentences name nothing that would have to be loaded, so a refusal costs no query it did not cost before. AllotmentQueryBudgetTest, HoldCostTest, SlotClaimCostTest and ConsumptionReadTest pass unchanged.

Tests. A new BookerRefusalTest covers the four cases that matter: the capacity refusal tells the booker what is left, a full offer says none are left, a constraint's own sentence comes through untouched, and a refusal nobody worded is answered generically and logged. All four were seen to fail against the old behaviour. BookingChannelRestrictionTest gains an assertion that the shown sentence names neither the tariff nor the front. Three existing tests asserted a booker sentence through getMessage() and now assert it through getTranslatedMessage().

LocalBookingClient forwards the booker sentence across the API seam, as it effectively did before; the technical message stays reachable as the chained previous exception.

Docs: api.md documents the two-sentence contract, constraints.md records that a constraint's reason is what the booker reads.

Merge request reports

Loading
Loading