<!--
	covers: bin commands migrations config/environments/console.php helpers/SchemaHelper.php
	verified: 3c56e1b
-->
# Console

[`commands/`](../../commands), 12 controllers under `app\commands`. The namespace is set in
`config/environments/console.php`; there is no controller map.

## `BaseController`

[`commands/BaseController.php`](../../commands/BaseController.php) — every console controller extends it. Its
`beforeAction()` does three things before anything runs:

```php
Yii::$app->db->schema->refresh();   // drop the cached schema
set_time_limit( 0 );
@ini_set( 'memory_limit', '2G' );
```

The schema refresh matters: without it a console command would use the schema cache written by the web
process, which can be stale right after a migration.

## The commands

### `deploy/flush-cache`

One line: `CacheHelper::flush()` → `Yii::$app->cache->flush()`. Clears the schema cache, the query cache, the
sitemap and anything else in the shared component. Run twice on deploy — see
[CACHING.md](CACHING.md).

### `search-index/build`

`SearchIndex::build()`: scan `models/*.php`, instantiate each, index every `SearchableInterface` record in
every language. Prints a per-model `indexed / removed` count. See [SEARCH.md](SEARCH.md).

### `files/pictures-to-optimize`

`PictureToOptimize::generateShellScript()` — writes `runtime/pictures-to-optimize.sh` and **deletes the queue
rows**. The cron runs the script immediately after. See [MEDIA.md](MEDIA.md).

### `payments/generate-by-rents [date]`

Generates the payment records due from the active rentals. Without an argument it works on today; passing a
date catches up a missed day. See [RENTAL-AND-BILLING.md](RENTAL-AND-BILLING.md).

### `billingo/sync-partners`, `billingo/invoice`, `billingo/sync`

The invoicing pipeline, run every minute in that order: push customers, issue invoices, read back state.

### `gate-sync/auto`, `gate-sync/reconcile`, `gate-sync/register <locationId>`, `gate-sync/dump <locationId>`

TELL Gate Control PRO. `auto` drains the queue, `reconcile` compares device state against the database;
both are gated by `APP_GATE_SYNC_ENABLED` and share the mutex `GateSyncController::MUTEX`. `register` and
`dump` are manual, per-location operations. See [GATE-SYNC.md](GATE-SYNC.md).

### `translate/model <Model>`, `translate/auto`

AI translation via the Anthropic API. `model` translates a whole model on demand; `auto` drains
`TranslationQueue`, gated by `APP_AUTO_TRANSLATE_ENABLED` and the `translate-auto` mutex. See
[I18N-AND-URLS.md](I18N-AND-URLS.md).

### `users/delete <idOrEmail>`

Deletes a user by id or e-mail address.

### `dev/create-widget <Name>`, `dev/create-entry <controllerId> <actionId> [widgets…]`

Scaffolding, not used in production. `create-widget` writes the widget class, its view, its SCSS and its JS in
one go — using it is the reliable way to get the four-part naming chain right. `create-entry` writes an Encore
entry that imports its SCSS and initializes the named widgets. See [FRONTEND.md](FRONTEND.md).

### `import/*` — the Drupal re-import

`report`, `catalogue`, `photos`, `occupancy`, `contents-manifest`, `export-xlsx`, `import-xlsx <file>`. Every
one takes `--dryRun=1`. Unlike `legacy/*` below, these are meant to be run repeatedly against a moving
source: idempotent, keyed on a legacy id, and refusing to touch a column the editors own. The scratch
databases they read come from [`bin/import-db`](../../bin/import-db). Full account in
[MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md).

### `legacy/migrate` and its six siblings

`migrate`, `migrate-slugs`, `migrate-company-storage-types`, `migrate-article-pictures`,
`migrate-storage-type-pictures`, `migrate-location-pictures`, `migrate-storage-pictures`.

One-off importers from the previous system, reading the payloads under `data/`. **Superseded by `import/*`
and not to be run again**: they delete and re-insert every record carrying a `legacyId`, which would erase a
year of editorial work, and they download every image over HTTP. Kept as the record of the 2025 migration —
see [ADR 0035](../adr/0035-the-drupal-re-import-is-incremental.md).

## Migrations

The files in [`migrations/`](../../migrations):

| Migration | Does |
|-----------|------|
| `m211113_052026_init.php` | the whole schema — one `SchemaHelper::create()` call per model |
| `m251115_113002_search_index.php` | creates `SearchIndex` |
| `m251120_144026_location_properties.php` | creates `LocationPropertyGroup`, `LocationProperty`, `LocationPropertyValue` |
| `m260808_101500_index_drift.php` | baselines two indexes left inconsistent by the retired `changes.sql` process |
| `m260808_143000_user_token_email_change_type.php` | corrects the misspelled `emailChane` token type in existing rows |
| `m260808_160000_location_description.php` | adds `location.description` |
| `m260808_170000_location_description_content.php` | generates the copy for every empty one, in all three languages |
| `m260907_100000_location_legacy_keys.php` | repoints `location.legacyId` at the `helyszin` taxonomy term, empties `legacyPicture`, deletes the orphaned migrated addresses |
| `m260907_120000_storage_availability.php` | adds `storage.availability` and `availabilityUntil` |

The table-creating ones go through [`SchemaHelper`](../../helpers/SchemaHelper.php), which instantiates the
model and reads `getColumns()` / `getIndexes()` off it. **A migration never spells out a column** — except
when it adds a single one, which `SchemaHelper` cannot do; then `addColumn()` has to repeat exactly what the
model declares, or the schema check reports drift.

`m260808_170000_location_description_content.php` is the only **content** migration. It reads address, unit
count, storage types, size range, lowest price and the boolean storage properties straight from the tables
and assembles a sentence set per language, so it produces sensible copy on production too, where the data is
different. It only writes descriptions that are empty, which makes it safe to run twice, and its `down()`
only clears what still matches what it wrote.

```php
( new SchemaHelper( $this ) )
    ->create( LocationPropertyGroup::class )
    ->create( LocationProperty::class )
    ->create( LocationPropertyValue::class );
```

Consequences:

- `SchemaHelper::create()` reflects the model **as it is today**, not as it was when the migration was
  written. Re-running the init migration on an empty database produces the current schema, not the 2021 one.
- A column added to a model without a migration exists on fresh installs and is missing everywhere else.
- FULLTEXT indexes cannot be expressed through `getIndexes()` — `SearchIndex::initData()` and
  `FontAwesomeIcon::initData()` add them with raw `ALTER TABLE`. See [SEARCH.md](SEARCH.md).

### Indexes that `getIndexes()` will never report

A drift check on 2026-08-08 compared every model's `getColumns()`/`getIndexes()` against **both the dev
database and a production schema export**. Both matched exactly — 53 model tables, no missing column, no extra
column, no orphan table, and after `m260808_101500_index_drift.php` no index difference either. Three index
differences remain in both environments and are all by design:

| Table | Index | Why |
|-------|-------|-----|
| `search_index` | `ix_search_index_dataLevel1..3` | FULLTEXT, added by `SearchIndex::initData()` |
| `font_awesome_icon` | `ix_full_text_font_awesome_icon_icon`, `…_label`, `…_searchTerms` | FULLTEXT, added by `FontAwesomeIcon::initData()` |
| `picture_to_optimize` | `filename` | column-level `->unique()`, so MySQL names it after the column |

Anything else appearing in a future check is real drift. The two that were found — a declared
`ix_customer_billingoPartnerDirty` that had never been created, and an `ix_company_invoiceFileId` created by
hand on the `payment` table under the wrong prefix — were fixed by `m260808_101500_index_drift.php`.

## Running commands

```shell
docker exec -ti raktar24-php-fpm ./yii <command>
docker exec -ti raktar24-php-fpm ./yii help
```

`./yii help` enumerates what the application actually exposes — trust it over any list, including this one.

## Trap: static memoization in long-running commands

The repository services memoize with `static $x; $x ??= …`, which lives for the whole process. In a web
request that is one page; in a console command looping over thousands of records it is the entire run. A
command that mutates data and then reads it back through a service will see the pre-mutation value. See
[SERVICES.md](SERVICES.md).

**This has already cost a real bug.** `Storage::getValues()` memoizes *every* `storage_property_value` row in
a process-wide static, and `Storage::updateComputedProperties()` — which maintains `area` and `volume` from
`StoragePropertyValue::afterSave()` — reads through it. During an import that writes property values it
therefore computes from a snapshot taken before the run started, and a newly created unit ends up with
`area = 0`. Both importers write those two columns themselves, with `updateAll()`, after the property values.

`AvailabilityService::reset()` and `ImportDiffService::reset()` exist for the same reason; call them after
writing what they memoize.
