# 0034. The availability rule has one owner

- **Status:** Accepted
- **Date:** 2026-09-07

## Context

"Is this unit free?" was answered in seven places, three of them in raw SQL:

| Where | Serves |
|---|---|
| `StorageService::usage()` | the public calendar |
| `StorageSearchService::occupiedIds()` | the search listing's default ordering |
| `LocationService::getFreeStorageCount()` | the free-unit counts on the location cards — raw `COUNT` |
| `Location::getPublishedStorages()` | the SVG floor map's colouring and the unit list — a `LEFT JOIN` on `rent` |
| `StoragesController::getStatusSelect()` | the admin grid's status dot and its filter — raw SQL |
| `OverviewTable::getStatus()` | the back-office overview |
| `StorageSearchService::occupied()` | the search listing's start-date facet — the same question, on **another** date |

They had already drifted. `getStatusSelect()` tested `CURRENT_DATE <= dateTo`, which is never true for an
open-ended rental (`dateTo IS NULL`), so a unit with a live open-ended contract showed as **free** in the
admin grid while the site correctly showed it as taken. Nobody had noticed, because the two answers are
never seen side by side.

Adding the manual occupancy override of [0033](0033-occupancy-overrides-live-on-the-storage.md) meant
touching all of them. Copying one more condition into seven implementations, three of them SQL, would
guarantee the next divergence.

> The seventh was found the hard way: the first version of this change counted six, removed the private
> `rents()` helper they shared, and missed `occupied()` — reachable only through the start-date facet, which
> no smoke test had hit. Production threw `Call to undefined method` on `/tarolok` until
> `occupiedIdsOn( $date )` was added.

`occupied()` asks the question about a *future* date, which is why the service answers for an arbitrary day
and not only for today.

The performance constraint is real and pulls the other way: a location page colours up to a hundred units and
the search listing filters five hundred, so the rule cannot cost a query per unit.

## Decision

**`services/AvailabilityService.php` owns the rule.** Every one of the six call sites asks it.

It answers two different questions, and keeps them separate on purpose:

- **`state()`** — what to show: `free`, `pending` or `reserved`.
- **`isFree()`** — whether the unit counts as available, which is what the free-unit counts and the default
  ordering use.

They differ in exactly one case, and that case is the behaviour the site already had: an open quote request
shows the unit as `pending` on the calendar but still **counts as free**, because a lead is not a booking. A
manual `CONDITIONAL` override does block.

**Three cached queries per request, whatever the size of the list.** The first call loads the overrides, the
live rentals and the open quote requests, derives a `storageId => state` map and a `storageId => intervals`
map, and everything after that is an array lookup.

The one accepted exception is `StoragesController::getStatusSelect()`. The admin grid **filters and sorts on
that column in SQL**, over a paginated query, so it cannot read the value out of PHP. It stays an SQL
expression — but the value-to-label mapping comes from `StorageAvailability::sqlState()`, so the mapping
itself still exists once.

## Alternatives

- **Copy the override condition into all six.** Rejected: that is how the six diverged in the first place.
- **Keep the SQL versions and add the override to each expression.** Same problem, and it leaves the free
  count and the listing unable to share a single answer.
- **Move the admin grid's status out of SQL too.** It would have to fetch every unit to filter and sort, on
  a screen that exists to page through five hundred of them.
- **A database view or a generated column.** Would answer the SQL side, but not the calendar's intervals,
  and it would put the business rule in the schema where a migration is needed to change it.

## Consequences

- **The rule is in one file**, and the seven call sites are three lines each.
- **Fewer queries than before, not more.** `usage()` ran two uncached queries of its own,
  `getFreeStorageCount()` a separate raw `COUNT`, and `getPublishedStorages()` carried a `LEFT JOIN` in the
  listing query. All three are gone.
- **The open-ended-rental bug in the admin grid is fixed** as a side effect of rewriting the expression.
- **`Storage::$available` is gone.** It was a public property set by one method and read by two views, and a
  `Storage` from anywhere else carried `null` — which rendered as "reserved". The views ask the service.
- **`Location::getPublishedStorages()` sorts free-first in PHP.** `usort` is stable in PHP 8, so the price
  and name ordering the query establishes survives inside each group; the list is at most a hundred rows.
- **The memoization is process-wide**, so a console command that writes availability has to call
  `AvailabilityService::reset()` before reading it back — the same trap as the other services
  ([0004](0004-static-repository-services.md)).
- **The rule is now evaluated twice**, in PHP and in the admin grid's SQL. That is a real cost, mitigated by
  `sqlState()` holding the mapping, and it is the price of a grid that pages and filters in the database.
