<!--
	covers: models modules/admin/contracts widgets/SvgMap.php
	verified: 3c56e1b
-->
# Domain model

63 classes in [`models/`](../../models) plus 10 form models in `models/forms/`. Not all of them are
ActiveRecords — check the base class before assuming a table exists.

## What is not an ActiveRecord

| Class | Is | Purpose |
|-------|----|---------|
| `RentStatus`, `PaymentStatus`, `PaymentMethod`, `StorageAvailability` | plain class of constants + statics | status/method vocabularies — **no table** |
| `BreadcrumbItem`, `MenuItem`, `Tab`, `Right` | `yii\base\Model` | view-layer value objects |
| `SearchableInterface` | interface | opt-in to search indexing |
| `SvgMap` | **trait** | injects floor-map columns and relations into a model |

`RentStatus` and `PaymentStatus` are the ones that surprise people — they look like lookup tables and are not.
The values live in the owner's `status` column; the class is the only schema. See
[RENTAL-AND-BILLING.md](RENTAL-AND-BILLING.md).

`StorageAvailability` follows the same pattern for `storage.availability` — `AUTO` / `FREE` / `CONDITIONAL` /
`OCCUPIED`, the manual occupancy override, bounded by `storage.availabilityUntil`. See
[STORAGE-AND-LOCATIONS.md](STORAGE-AND-LOCATIONS.md#occupancy).

## Inheritance

```
ActiveRecord
 ├─ File ── Picture
 └─ Location ─┬─ Office     ( type = 'office',   isPublished forced false )
              └─ AdSpace    ( type = 'ad-space', isPublished forced false )
```

`Office` and `AdSpace` are **single-table subclasses**: they override `tableName()` back to `Location`'s and
force `type` in both `beforeValidate()` and `beforeSave()`. They also force `isPublished = false` — an office
or ad space is never a public page.

That is why the admin has three controllers over one table. See
[STORAGE-AND-LOCATIONS.md](STORAGE-AND-LOCATIONS.md).

## Catalogue

Multilingual attributes are listed where present; a display route means the model has a public page.

### Storages and locations

| Model | Display route | Multilingual | Searchable |
|-------|---------------|--------------|-----------|
| `Storage` | `storages/display` | `name`, `nameInContract`, `description` | ✓ |
| `StorageType` | `storages/type` | `name`, `lead`, `body` | ✓ |
| `Location` | `locations/display` | `name`, `shortName`, `description` | ✓ |
| `Office`, `AdSpace` | — | inherited | — |
| `Area` | — | `name` | — |
| `StorageProperty`, `StoragePropertyGroup`, `StoragePropertyOption` | — | `name` | — |
| `StoragePropertyValue` | — | `valueString` | — |
| `LocationProperty`, `LocationPropertyGroup` | — | `name` | — |
| `LocationPropertyValue` | — | `valueString` | — |
| `LocationEvent`, `LocationEventType` | — | `name` (type) | — |
| `Address`, `Country` | — | `district`, `name` | — |

### Rentals, billing, parties

| Model | Notes |
|-------|-------|
| `Rent` | the contract; `HasNotifications` |
| `RentStatusChange`, `RentEvent`, `RentEventType` | history and typed event log |
| `Payment` | one billable line; `HasNotifications` |
| `PaymentItem`, `PaymentStatusChange` | invoice lines, status history |
| `TaxRate` | multilingual `name`, `code`; seeded by `initData()` |
| `Company`, `CompanyStorageType` | the renting-out entity and what it offers |
| `Customer`, `CustomerCompany` | the renting party |
| `DoorOpeningPhoneNumber`, `GateSyncQueue` | gate access — see [GATE-SYNC.md](GATE-SYNC.md) |

### Sales funnel

| Model | Notes |
|-------|-------|
| `ProposalRequest` | `HasNotifications`; statuses are sortable strings `10_New` … `50_Declined` |
| `ProposalRequestItem` | one quoted unit; the source of a `Rent` |
| `ProposalRequestService` | join to `Service` — **collides with `services/ProposalRequestService`** |
| `ProposalRequestStatusChange` | history |
| `CallbackRequest`, `Contact` | leads; both `HasNotifications` |
| `Service` | `services/display`, multilingual `name`, `description`, searchable |

### Content

| Model | Display route | Multilingual |
|-------|---------------|--------------|
| `Article` | `articles/display` | `name`, `lead`, `body` — searchable |
| `Content` | `contents/display` | `name`, `shortName`, `body` — searchable |
| `Faq` | — | `question`, `answer` |
| `Review` | — | `name`, `companyName`, `review` |
| `Hero` | — | — |
| `AdSpace` | — | (a `Location`) |

### Media and infrastructure

| Model | Notes |
|-------|-------|
| `File`, `Picture`, `Photo`, `Attachment` | see [MEDIA.md](MEDIA.md) |
| `PictureToOptimize` | compression queue |
| `SearchIndex` | see [SEARCH.md](SEARCH.md) |
| `TranslationQueue` | see [I18N-AND-URLS.md](I18N-AND-URLS.md) |
| `Settings` | singleton config record, read through `p()`; multilingual `opening` |
| `FontAwesomeIcon` | the icon picker's catalogue, behind `i()` |
| `NewsletterSubscription` | — |
| `User`, `UserToken` | see [USERS-AND-ACCESS.md](USERS-AND-ACCESS.md) |

## `Settings` is a singleton

`Settings::get()` returns the single configuration record. `p( 'name', $default )` reads one attribute off it
and is the idiomatic accessor.

Every scalar `Settings` attribute is also a **template placeholder**: `View::applyReplaces()` substitutes
`{snake_case_attribute}` throughout the finished HTML, so `{invoice_expiry_alert_days}` in any view or in
editor content resolves. See [REQUEST-LIFECYCLE.md](REQUEST-LIFECYCLE.md#7-output-rewriting).

Two of its attributes drive the admin dashboard warnings: `invoiceExpiryAlertDays` and
`contractExpiryAlertDays`.

## `HasNotifications`

[`modules/admin/contracts/HasNotifications.php`](../../modules/admin/contracts/HasNotifications.php) — a model
implementing it supplies `getNotificationHtml()` and can appear in a dashboard notification group.

Implemented by: `Rent`, `Payment`, `ProposalRequest`, `Contact`, `CallbackRequest`, `LocationEvent`.

## Legacy columns

Several models carry `legacyId`, `legacySlug`, `legacyPicture` (JSON), and `StorageProperty` adds
`legacyField`, `legacyType`, `legacyModule`. They are indexed, and they are **load-bearing**, not leftovers:
`legacyId` is the key the repeatable Drupal re-import matches on, and `StorageProperty.legacyField` /
`legacyModule` are what pick the conversion for a property value. See
[MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md).

Two of them mean something different from what their name suggests:

- **`location.legacyId` is a `helyszin` taxonomy term id**, not a node id — the term is the reliable site
  key, and `m260907_100000_location_legacy_keys` repointed it.
- **`location.legacySlug` is not source-derived at all** but a hand-written short alias, and
  `ContentsController::actionDisplay()` serves the old site's 301 redirects from it. Nothing overwrites it.
- **`location.legacyPicture` is empty** and stays so; the floor-plan photos come from
  `data/import/maps.php` instead.
- **`photo.legacyId` is a Drupal file id and is not unique** — one image on three units is three photos.

## Form models

[`models/forms/`](../../models/forms) — `Form` (base), `Login`, `Register`, `Profile`, `PasswordChange`,
`PasswordReminder`, `Contact`, `CallbackRequest`, `Offer`, `StorageSearch`.

**`Contact` and `CallbackRequest` exist in both namespaces** — `app\models\Contact` is the persisted lead,
`app\models\forms\Contact` is the form. The `use` statement decides which one you got.

## Naming collisions to remember

| Name | Exists as |
|------|-----------|
| `ProposalRequestService` | `app\models\…` (join record) and `app\services\…` (service) |
| `Contact` | `app\models\…` and `app\models\forms\…` |
| `CallbackRequest` | `app\models\…` and `app\models\forms\…` |
| `ActiveRecord` | `app\db\ActiveRecord` and `yii\db\ActiveRecord` |
| `Controller` | `app\controllers\Controller`, `app\commands\BaseController`, `app\modules\admin\controllers\BaseController` |
| `SvgMap` | `models/SvgMap.php` is a **trait**; `widgets/SvgMap.php` is a widget |
| `User` | `app\models\User` (record) and `app\components\User` (the web user component) |
