<!--
	covers: controllers/LocationsController.php controllers/StoragesController.php controllers/StorageTypesController.php helpers/Property.php models/AdSpace.php models/Area.php models/Location.php models/LocationEvent.php models/LocationEventType.php models/LocationProperty.php models/LocationPropertyGroup.php models/LocationPropertyValue.php models/Office.php models/Storage.php models/StorageAvailability.php models/StorageProperty.php models/StoragePropertyGroup.php models/StoragePropertyOption.php models/StoragePropertyValue.php models/StorageType.php models/SvgMap.php models/forms/StorageSearch.php services/AvailabilityService.php services/StorageSearchService.php
	verified: d5001ac
-->
# Storages and locations

The catalogue side of the domain: where the units are, what they are, and how they are found.

## `Location` — one table, three entities

[`models/Location.php`](../../models/Location.php), 558 LOC. A `type` column splits it into three things that
the admin presents as three separate screens:

```php
const string
    TYPE_LOCATION = 'location',
    TYPE_OFFICE   = 'office',
    TYPE_AD_SPACE = 'ad-space';
```

`LocationsController`, `OfficesController` and `AdSpacesController` in the admin are all the same table with a
different `propagates` filter. The dashboard tiles count them separately with `counterWhere`.

| Column group | Columns |
|--------------|---------|
| Identity | `type`, `name`, `shortName` (16 chars), `slug`, `description` |
| Placement | `addressId`, `isPublished` |
| Gate control | `gateEnabled`, `gateHwId` (32), `gateAppId` (64), `gateSchemeId` (64) |
| Floor map | contributed by the `SvgMap` trait — `getSvgMapColumns()` / `getSvgMapIndexes()` |
| Legacy | `legacyId`, `legacySlug`, `legacyPicture` (JSON) |

Multilingual: `name`, `shortName`, `description`. Slug from `name`. Display route `locations/display`.

`description` is editorial HTML shown as the **first tab** of the location page, before the floor map — added
for search engines, so the tab that opens is still the storage map
([`views/locations/display.php`](../../views/locations/display.php)). It is edited on the Raktár helyszín
screen only; `AlterLocationsController` filters the field out of the office and ad-space forms.

**It also becomes the page's meta description**, because `View::getMeta( 'description' )` reads the displayed
model's attribute of that name. `m260808_170000_location_description_content` filled every empty one with
copy generated from the data — address, unit count, types, size range, lowest price and the boolean storage
properties — in all three languages.

`Location` implements `SearchableInterface` and owns `storages`, `photos`, `events`, `attachments`, `areas`,
`payments` and its `address`.

> **The `SvgMap` trait is `use`d, not extended.** `models/SvgMap.php` is a trait that injects the floor-map
> columns into `getColumns()` via `+ $this->getSvgMapColumns( $migration )`. `Storage` carries the matching
> `svgNodeId` — the id of the node inside the location's SVG that represents that unit.

Gate columns are on the **location**, not the storage: one physical gate per site. See
[GATE-SYNC.md](GATE-SYNC.md).

## `Storage` — the rentable unit

[`models/Storage.php`](../../models/Storage.php), 652 LOC.

| Column group | Columns |
|--------------|---------|
| Identity | `name`, `nameInContract`, `description`, `slug` |
| Placement | `locationId`, `storageTypeId`, `areaId`, `svgNodeId` |
| Physical | `area` decimal(8,2), `volume` decimal(8,2) |
| Flags | `isPublished`, `isFeatured`, `isNew`, `isPremium` |
| Occupancy override | `availability`, `availabilityUntil` |
| Pricing | `price1M`, `price3M`, `price6M`, `price12M` |
| Legacy | `legacyId`, `legacySlug`, `legacyPicture` |

Multilingual: `name`, `nameInContract`, `description`. Display route `storages/display`. Searchable.

### Prices are four columns, not a table

`price1M` / `price3M` / `price6M` / `price12M` are the monthly price at each commitment length, stored inline
as unsigned integers (forint, no decimals). There is no price-history table and no per-customer pricing.

`StorageService::minPrice( $storage )` picks the lowest **non-zero** of the four and formats it as
`amount( $price ) . t( 'from' )`, falling back to an em dash. A zero price means "not offered at that term",
not "free".

### Dimensions live in the property system

`area` and `volume` are real columns, but width/length/height are **properties**:

```php
StorageService::sizes( $storage );   // "$w × $l × $h cm"
// reads $storage->width?->valueInteger, ->length, ->height
```

Those accessors resolve through `StoragePropertyValue`, so a unit with no dimension properties formats as
`0 × 0 × 0 cm` rather than failing.

## The property system

Storages and locations each have a generic, editor-defined attribute system. The two are parallel but
**separate tables** — they share a shape, not code.

```
StoragePropertyGroup   ── grouping in the UI
  └─ StorageProperty       name, codeName (unique), type, displayInContract, isPublished
       ├─ StoragePropertyOption   the choices, for SELECT / MULTISELECT
       └─ StoragePropertyValue    storageId + storagePropertyId + one of four value columns
```

`LocationPropertyGroup` / `LocationProperty` / `LocationPropertyValue` mirror this for locations (added by
`m251120_144026_location_properties.php`).

### Five types, four value columns

```php
TYPE_TEXT  TYPE_NUMBER  TYPE_SELECT  TYPE_MULTISELECT  TYPE_BOOLEAN
```

`StoragePropertyValue` carries `storagePropertyOptionId`, `valueInteger`, `valueString`, `valueBoolean` — all
nullable — and **the validation rules pick which one is required based on the property's type**:

```php
[ 'valueInteger', 'required', 'when' => fn( $v ) => $v->property?->type === StorageProperty::TYPE_NUMBER ],
[ 'valueString',  'required', 'when' => fn( $v ) => $v->property?->type === StorageProperty::TYPE_TEXT ],
[ 'valueBoolean', 'required', 'when' => fn( $v ) => $v->property?->type === StorageProperty::TYPE_BOOLEAN ],
[ 'storagePropertyOptionId', 'required', 'when' => fn( $v ) => in_array( $v->property?->type, [ TYPE_SELECT, TYPE_MULTISELECT ] ) ],
```

The `isEmpty` callbacks come from [`helpers/Validation.php`](../../helpers/Validation.php) — `isEmptyInt`,
`isEmptyBool`, `isEmpty` — because `0` and `false` are valid values that Yii's default emptiness check would
reject.

> **These rules have never run.** Property values are always written with `save( false )`
> ([`models/Storage.php:325`](../../models/Storage.php)) and have no admin controller of their own, so
> nothing validates the 14 040 rows in the table. Two of the rules were wrong for exactly that reason and
> were fixed on 2026-08-08 — a `max => 0` on `valueString`, and an inverted `Validation::isEmptyBool()` that
> made every boolean value fail `required` while `null` passed. Treat the rest of the method as unverified.

`codeName` is `unique` and is how code refers to a property without hard-coding an id.

`Location` caches its values in `protected ?array $_values` and stages writes in `$valuesToSave`, so property
edits are applied as one batch after the parent record saves.

## `Area` and `StorageType`

- **`Area`** — a **level or zone of one site**, not a size band: `locationId` + multilingual `name`
  (Földszint, Emelet, Kültér) plus its own floor map through the `SvgMap` trait. `Storage.areaId` says which
  level a unit is on, and `StorageListWidget` filters the list by it. The size dropdown is something else
  entirely — `StorageSearchService::sizeFacet()` and `StorageService::sizeKey()`, an S/M/L/XL bucketing of
  `area` with no table behind it.
- **`StorageType`** — indoor / outdoor and their variants. Multilingual `name`, `lead`, `body`; searchable;
  display route `storages/type`. `CompanyStorageType` joins it to `Company` for per-company availability.
  `APP_STORAGE_TYPE_INDOOR_ID` and `APP_STORAGE_TYPE_OUTDOOR_ID` hard-code two of them in `env.php` —
  **those ids must exist in the database and match**.

## A unit is public only if its site is

Two conditions, always both:

```php
$storage->isPublished && $storage->location?->isPublished
```

`Storage::isSearchable()` spells it out, and `StorageSearchService::storages()` — the published-unit index the
whole listing is built on — joins `location` for it. **Anything that lists units has to honour it**, because
`$storage->location` resolves through `LocationService`, which indexes published sites only: a published unit
at an unpublished site gets a `null` location and `views/parts/storage-info.php` dereferences it.

That state is not a data error. The importer creates a new site unpublished and its units published, so
publishing the site is the single switch that makes them appear — see
[MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md).

## Search and filtering

[`services/StorageSearchService.php`](../../services/StorageSearchService.php) — the largest service. It
builds the storage listing query with cascading filters (location, type, area band, properties) and serves
the AJAX responses behind `StorageFilterWidget` / `StorageSearchWidget`.

`occupiedIds()` is the one thing there that is not filtering: the ids a rent covers today, derived from the
already-loaded `rents()`, used by the default ordering.

### Ordering is one key

[`models/forms/StorageSearch.php`](../../models/forms/StorageSearch.php) carries a single `sort` attribute,
a key of `SORTS`, instead of a field/direction pair:

| Key | Order |
|-----|-------|
| `default` | free today first, then cheapest |
| `price-asc` / `price-desc` | `LEAST()` of the four non-zero price columns |
| `size-asc` / `size-desc` | `area` |
| `distance-asc` / `distance-desc` | distance from the visitor |

- **A zero price never wins the `LEAST()`** — it is swapped for `9999999999` ascending and `-1` descending,
  so the substitution follows the direction the expression is sorted in.
- **`default` prepends `` `id` NOT IN ( … ) `` DESC** from `occupiedIds()`, and leaves the term out entirely
  when nothing is rented: `ORDER BY 1` would mean the first selected column, not TRUE.
- **Distance needs the `r24-geo` cookie** the browser writes; without it the request silently falls back to
  `default` and says so by resetting `sort`, which is what the dropdown renders. See
  [ADR 0025](../adr/0025-distance-ordering-from-a-browser-cookie.md).
- The list is rendered by two controls that share the name `s[sort]` — radios inside the search form below
  `lg`, a select in the results summary above it. `resources/js/entries/storagesIndex.js` keeps them in sync,
  because PHP takes the last occurrence in the query string.

`sizeKey( float $area )` maps an area to the same S/M/L/XL bucket the size filter uses, for the cards and the
product page. Its boundaries have to stay identical to `StorageSearchService::sizeFacet()`.

**This is not the full-text search.** `SearchIndex` indexes `Storage` for the site-wide keyword search; the
filterable listing is a plain query builder. The two share nothing. See [SEARCH.md](SEARCH.md).

The frontend routes are all under `{storages}`:

```
{storages}                     storages/index
{storages}/budapest/<slug>     storages/district     (more specific, must come first)
{storages}/<location>/<slug>   storages/display
{storages}/<slug>              storages/city
{storageTypes}/<slug>          storages/type
```

## Occupancy

**[`services/AvailabilityService.php`](../../services/AvailabilityService.php) owns the rule, and nothing
else answers it.** It used to be spelled out in six places, three of them in raw SQL, and they had already
drifted — see [ADR 0034](../adr/0034-the-availability-rule-has-one-owner.md).

Two questions, two answers, and they differ in exactly one case:

| Method | Answers | Used by |
|---|---|---|
| `state( $id )` | what to show: `free` / `pending` / `reserved` | the calendar, the admin grid, the overview |
| `isFree( $id )` | whether it counts as available | the free-unit counts, the default ordering |
| `intervals( $id )` | the blocks the calendar draws | `StorageService::usage()` → `CalendarWidget` |
| `occupiedIds()` | the ids that do not count as available | `StorageSearchService::occupiedIds()` |
| `occupiedIdsOn( $date )` | the same, on **another** day | `StorageSearchService::occupied()` — the start-date facet |

**An open quote request shows the unit as `pending` but still counts as free** — a lead is not a booking, and
that was the behaviour before the service existed. A manual `CONDITIONAL` override does block.

### Cost

**Three cached queries per request, whatever the size of the list**: the overrides, the live rentals and the
open quote requests. Everything after that is an array lookup, so colouring four hundred units on a location
page costs the same as colouring one, and asking about a second date costs nothing. That is *fewer* queries
than the code it replaced.

`overrideState( $storageId, $date )` is the one place the override's expiry is evaluated, which is what lets
the same three sources answer for today and for a date a month out.

### The manual override

`storage.availability` is one of [`StorageAvailability`](../../models/StorageAvailability.php)'s four values —
`AUTO`, `FREE`, `CONDITIONAL`, `OCCUPIED` — a vocabulary class, not a table (ADR 0012). `AUTO` is the default
and means "derive it". `availabilityUntil` bounds an override, and **the expiry is evaluated on read**: past
that day the unit simply reads as `AUTO`, with nothing to reset.

It exists because at go-live 326 units were rented under contracts that live in the previous system. Importing
those as rentals would have generated payments and Billingo invoices, for renters that cannot be identified —
see [ADR 0033](../adr/0033-occupancy-overrides-live-on-the-storage.md) and
[MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md).

The override can never contradict a contract, in either direction:

- `Storage::rules()` refuses a manual value on a unit with a rental in a non-ending status whose end date is
  today or later (`Storage::hasLiveRent()`), and `StoragesController::availabilityOptions()` offers only
  `AUTO` there;
- `Rent::afterSave()` resets the unit to `AUTO` when such a rental is saved — which is what makes the
  overrides drain away by themselves as the carried-over rentals get recorded.

### The one place it is still SQL

`StoragesController::getStatusSelect()`. The admin grid filters and sorts on the status column in SQL over a
paginated query, so it cannot read the state out of PHP. The value-to-label mapping still lives once, in
`StorageAvailability::sqlState()`.

## Traps

1. **`Location.type` is a string, not an enum table.** A typo produces a row that appears on no admin screen.
2. **Three admin controllers share one table.** A change to `Location` fields shows up on all three screens
   unless the field declaration differs per controller.
3. **`APP_STORAGE_TYPE_INDOOR_ID` / `_OUTDOOR_ID` are hard-coded ids.** Reseeding storage types on a new
   environment without fixing these silently mis-categorises the homepage.
4. **Zero prices are "not offered", not free** — `minPrice()` skips them, but a raw read does not.
5. **`svgNodeId` is a free-text link into the location's SVG.** Nothing validates that the node exists; a
   mismatch just means the unit is not highlighted on the map.
6. **`StoragePropertyValue::rules()` never executes** — see above. Anything you add there is untested by
   construction.
7. **Free-unit counts are two different functions.** `LocationService::getStorageCount()` is every published
   unit; `getFreeStorageCount()` is those with no rent covering today, and it is what the location cards and
   the search results show. Both are memoized for the whole request.
8. **`sizeKey()` and `sizeFacet()` share the S/M/L/XL boundaries by hand**, not by calling one another.
   Changing `SIZES` semantics in one place and not the other makes the card disagree with the filter.
9. **`Area` is a level of a site, not a size band.** The name invites the other reading; `Storage.area` is
   the square metres and `Storage.areaId` is the floor.
10. **`AvailabilityService` memoizes for the whole process.** A console command that writes availability has
    to call `reset()` before reading it back.
11. **A published unit at an unpublished site has a `null` `location`.** `LocationService` serves that
    relation and indexes published sites only. Every listing has to filter on both flags; see above.
12. **The admin form shows the raw `availability`, the site shows the effective state.** An override whose
    end date has passed still reads `OCCUPIED` on the form while the site treats the unit as free. That is
    the price of resolving the expiry on read, and it is deliberate.
