# Персайтовые переопределения

Каталог для кода и шаблонов, которые заказал один конкретный клиент.
Аналог легаси `/b/{login}` — с той разницей, что здесь у переопределения
ровно один вход и явный контракт, а не набор файлов, подключаемых
в произвольных точках шаблона.

```
overrides/{ключ}/
    Booter.php          класс Korzilla\Overrides\{Ключ}\Booter
    views/              шаблоны, перекрывающие тему и модули
```

Ключ — `override_key` сайта, по умолчанию его логин.

## Шаблоны

Порядок поиска: `overrides/{ключ}/views` → `themes/{тема}` → views модулей →
база. Достаточно положить файл с тем же именем — платформенный код при этом
не трогается.

## Код

```php
<?php

namespace Korzilla\Overrides\Klient;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Korzilla\Core\Site\Override\SiteOverride;

final class Booter implements SiteOverride
{
    public function boot(Application $app): void
    {
        // Свой адрес — маршруты объявляются до разбора адреса роутером.
        Route::get('/spec-predlozhenie', fn () => view('spec'));
    }
}
```

`boot()` вызывается один раз за запрос, сразу после того, как подняты
контекст сайта, файловый диск и порядок шаблонов.

Здесь уместно: зарегистрировать свой тип блока или кнопку покупки, подписаться
на событие (заказ оформлен → отправить в чужую CRM), объявить маршрут,
подменить биндинг в контейнере.

**Никакого `eval`.** Файл подключается как обычный класс.

⚠️ **Ошибка в переопределении не роняет установку.** В продакшене она уходит
в журнал, и сайт работает дальше без персайтового кода: беда одного арендатора
не должна класть остальные 299. На разработке (`APP_DEBUG=true`) исключение
пробрасывается — молчаливое проглатывание означает часы поиска «почему мой код
не работает».

⚠️ **Файл есть, а класса в нём нет — ошибка, а не тишина.** Опечатка
в пространстве имён иначе оставила бы разработчика в уверенности, что его код
работает.

## Админка сайта

Аналог легаси `/b/{login}/manifest/*.json`. Три необязательных интерфейса
на том же `Booter`, реализуется любой набор:

| Интерфейс | Что даёт | Легаси |
|---|---|---|
| `ExtendsAdminMenu` | свои пункты меню — в любую группу, в том числе в «Магазин» | `manifest/menu_manifest.json` |
| `DeclaresSettings` | свои экраны настроек, форму рисует универсальный рендерер | `manifest/*.json` |
| `DeclaresAdminRoutes` | свои адреса админки под входом | — |

```php
<?php

namespace Korzilla\Overrides\Klient;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Korzilla\Core\Admin\AdminMenu;
use Korzilla\Core\Settings\Schema\SettingGroup;
use Korzilla\Core\Settings\Schema\SettingsSchema;
use Korzilla\Core\Site\Override\DeclaresAdminRoutes;
use Korzilla\Core\Site\Override\DeclaresSettings;
use Korzilla\Core\Site\Override\ExtendsAdminMenu;
use Korzilla\Core\Site\Override\SiteOverride;

final class Booter implements SiteOverride, ExtendsAdminMenu, DeclaresSettings, DeclaresAdminRoutes
{
    public function boot(Application $app): void {}

    public function adminMenu(AdminMenu $menu): void
    {
        $menu->group('shop', 'Магазин')
            ->link('crm-export', 'Выгрузка в CRM', route('admin.override.crm-export', absolute: false));
    }

    public function settingsSchemas(): array
    {
        return [
            SettingsSchema::make('klient.crm', 'Интеграция с CRM')
                ->group('main', 'Подключение', function (SettingGroup $g): void {
                    $g->text('klient.crm_url', 'Адрес CRM');
                    $g->secret('klient.crm_token', 'Токен');
                    $g->bool('klient.crm_enabled', 'Отправлять заказы')->default(true);
                }),
        ];
    }

    public function adminRoutes(): void
    {
        // Итоговый адрес — /admin/crm-export, имя — admin.override.crm-export.
        Route::get('crm-export', fn () => response()->download(storage_path('crm.csv')))
            ->name('crm-export');
    }
}
```

Экран настроек появляется в «Настройках» сам, значения читаются как обычно:
`app(SettingsRepository::class)->get('klient.crm_url')`.

⚠️ **Ключ схемы и каждого поля начинаются с ключа переопределения и точки**
(`klient.crm_url`), иначе схема не регистрируется. Реестр схем общий на процесс,
и два клиента с полем `crm.token` столкнулись бы — второй остался бы без своих
настроек. С префиксом столкновение невозможно, а в таблице настроек сразу
видно, чья это строка.

⚠️ **Не подписывайтесь на меню из `boot()` через `afterResolving`.** Колбэк
переживает запрос, и в долгоживущем процессе пункт одного арендатора появился
бы в админке другого. Интерфейсы от этого избавлены по построению: платформа
сама спрашивает переопределение текущего сайта.

⚠️ **Адрес админки объявляйте в `adminRoutes()`, а не в `boot()`.** Маршрут
из `boot()` стоит в группе сайта без проверки администратора, и выгрузку
заказов по адресу `/admin/…` получил бы любой, кто его угадал. В `adminRoutes()`
платформа сама кладёт адрес под вход, под префикс `/admin` и под свой сайт —
причём проверка сайта идёт раньше входа: на соседнем сайте такой адрес отвечает
404, а не предложением войти.

Убрать пункт платформы нельзя намеренно: реестр меню умеет только добавлять.

**Своих Vue-экранов у переопределения нет.** Бандл админки собирается один
на установку (`import.meta.glob` при сборке Vite), и страница клиента в нём
уехала бы всем сайтам и в каждую выгрузку. Ответ адреса админки — скачивание
файла, редирект с сообщением или страница Blade.

**В очереди и консоли переопределение не поднимается**: оно подключается
middleware запроса. Задача, читающая `klient.*`, получит значение из базы,
но не умолчание схемы — если оно важно, передайте его явно.
