# CLAUDE.md

Указания для Claude Code при работе с этим репозиторием.

## Проект

**KORZILLA X** — мультисайтовая CMS на Laravel 13 + MySQL 8. Переписывается с нуля;
легаси-версия живёт в `D:\laragon\www\kzla` (у неё свой CLAUDE.md) и переносу
не подлежит — новые сайты создаются на X, старые остаются на старой системе.

Масштаб цели: 300–500 сайтов на одной установке, общая БД, разделение по `site_id`.

План реализации: `C:\Users\Administrator\.claude\plans\polymorphic-frolicking-lamport.md`.

**Документация по узлам — в [`docs/`](docs/README.md).** Этот файл — короткая
выжимка для быстрого старта; за подробностями идти туда:

| Узел | Файл |
|---|---|
| Архитектура, инварианты, точки расширения | [docs/architecture.md](docs/architecture.md) |
| Модульная система | [docs/modules.md](docs/modules.md) |
| Сайты, домены, изоляция арендаторов | [docs/multitenancy.md](docs/multitenancy.md) |
| Разделы, адреса, редиректы | [docs/structure.md](docs/structure.md) |
| Компоненты, поля, шаблоны | [docs/components.md](docs/components.md) |
| Зоны, блоки, сетка | [docs/layout.md](docs/layout.md) |
| Каталог, цены, фасеты | [docs/catalog.md](docs/catalog.md) |
| Покупатели и вход | [docs/auth.md](docs/auth.md) |
| Корзина, способы покупки | [docs/shop.md](docs/shop.md) |
| Импорт из 1С | [docs/import.md](docs/import.md) |
| Поиск | [docs/search.md](docs/search.md) |
| SEO | [docs/seo.md](docs/seo.md) |
| Формы и заявки | [docs/forms.md](docs/forms.md) |
| JSON API и приложение | [docs/api.md](docs/api.md) |
| Платформа и супер-админ | [docs/platform.md](docs/platform.md) |
| Города и геотаргетинг | [docs/geo.md](docs/geo.md) |
| Выгрузка сайта | [docs/export.md](docs/export.md) |
| Админка | [docs/admin.md](docs/admin.md) |
| Настройки | [docs/settings.md](docs/settings.md) |
| Кэш | [docs/cache.md](docs/cache.md) |
| Схема БД | [docs/database.md](docs/database.md) |
| Окружение и соглашения | [docs/development.md](docs/development.md) |
| Эксплуатация, бэкапы, мониторинг | [docs/operations.md](docs/operations.md) |
| Этапы | [docs/roadmap.md](docs/roadmap.md) |

## Окружение

```
PHP        d:\laragon\bin\php\php-8.3.30-Win32-vs16-x64\php.exe
Composer   d:\laragon\bin\composer\composer.phar
MySQL      d:\laragon\bin\mysql\mysql-8.4.3-winx64\bin\mysql.exe  (root, без пароля)
Redis      d:\laragon\bin\redis\redis-x64-5.0.14.1\redis-server.exe  (запускать вручную)
Node       d:\laragon\bin\nodejs\node-v22\
БД         korzillax (разработка), korzillax_test (тесты)
```

- Расширения `redis` в PHP нет — используется `predis/predis` (`REDIS_CLIENT=predis`).
- Тесты идут **по MySQL**, а не по sqlite: схема опирается на особенности MySQL 8.
- ⚠️ **Heredoc в Git Bash съедает обратные слэши.** PHP-файлы с namespace писать
  инструментом Write, а не через `cat <<EOF`.
- ⚠️ Одинарные кавычки внутри `php -r '...'` в bash обрывают строку. Использовать
  `chr(39)` или отдельный файл-скрипт.

## Команды

```bash
php artisan module:discover        # пересобрать кэш модулей
php artisan module:list            # список модулей
php artisan module:make <key>      # заготовка модуля
composer gate                      # deptrac + pint + phpunit
php vendor/bin/phpunit             # только тесты
php vendor/bin/deptrac analyse --config-file=deptrac.yaml
```

## Три инварианта, которые нельзя нарушать

1. **`site_id` в каждой пользовательской таблице.** Таблицу нужно объявить
   в `TenantTables` (провайдер модуля) как `tenant` / `root` / `global` /
   `ignore` / `platform`.
   Тест `TenantTableInvariantTest` проверяет это в обе стороны и падает, если
   таблица заведена и забыта. В легаси инвариант нарушали `showing_blocks`
   и `redirect` — и делали персайтовый дамп невозможным.
2. **Пути к медиа в БД — относительные, без `{login}`.** Легаси вшил `/a/{login}/`
   в 1.75 млн строк `Multifield.Path`.
3. **Site-модуль не ссылается на platform-модуль.** Проверяет Deptrac; именно
   это делает безопасным вырезание платформенных модулей сборщиком выгрузки.

Плюс: **никакого `eval`** — ни в правах, ни в условиях видимости настроек,
ни в персайтовом коде клиента.

Плюс: **резолвер страниц — фолбэк-маршрут**, а не catch-all. Обычный
`/{path?}` перехватывает адрес раньше всего, что объявлено позже: маршрута
модуля, персайтового переопределения.

## Раскладка

```
src/Core/            ядро: Module, Site, Geo, Settings, Cache, Tenancy,
                     Structure, Component (+ Builder), Layout, Media, Admin
modules/site/{key}/  модули, которые едут в автономную копию сайта
modules/platform/    только платформа, вырезаются сборщиком выгрузки
themes/{key}/        вёрстка сайтов (Blade + Alpine)
overrides/{key}/     персайтовые переопределения (аналог легаси /b/{login})
resources/views/platform/  платформенные страницы (404, заглушка, корень админки)
resources/js/admin/        админка на Vue 3 + Inertia (отдельный бандл)
```

Модуль = `module.json` + `ServiceProvider` (наследник `ModuleServiceProvider`)
+ миграции + views + routes + config. Обнаружение — `module:discover`,
результат в `bootstrap/cache/korzilla-modules.php`; в рантайме ФС не сканируется.
Есть резервный PSR-4 автозагрузчик, поэтому новый модуль работает до
`composer dump-autoload`.

## Что уже сделано (M0 — M10 собраны, идёт M11)

| Подсистема | Где |
|---|---|
| Ядро модулей, реестр, генератор | `src/Core/Module` |
| Сайты и домены, создание сайта | `src/Core/Site`, `database/migrations/*_sites_*`, `*_site_domains_*` |
| Резолв хоста, канонизация, контекст | `src/Core/Site/{SiteResolver,SiteContext,Middleware}` |
| Кэш на счётчиках версий | `src/Core/Cache` |
| Настройки: хранение, схемы, шифрование | `src/Core/Settings` |
| Реестр таблиц арендатора | `src/Core/Tenancy/TenantTables` |
| Разделы, пути, переименование, авто-301 | `src/Core/Structure/{SectionWriter,SectionTree,Redirect*}` |
| Типы полей и компоненты | `src/Core/Field`, `src/Core/Component` |
| Резолв страниц и построение URL | `src/Core/Structure/{PageResolver,UrlBuilder}` |
| Зоны, блоки, видимость, сетка | `src/Core/Layout` |
| Содержимое раздела, писатель объектов | `src/Core/Component/ObjectWriter`, экран `Objects` |
| Конструктор типов страниц | `src/Core/Component/Builder` |
| Контентные модули (9 штук) | `modules/site/*` |
| Города и город запроса | `src/Core/Geo` |
| Каталог: схема, цены, фильтры, фасеты, админка | `modules/site/catalog` |
| Админка: гвард, меню, экраны | `src/Core/Admin`, `resources/js/admin` |
| Персайтовые администраторы (M6) | `src/Core/Admin/{AdminGuard,AdminWriter,Models}`, `*_create_admins_table` |
| Покупатели и вход по звонку (M6) | `src/Core/User`, `*_create_site_user_tables` |
| Корзина и способы покупки (M6) | `modules/site/shop`, `catalog/PurchaseActions` |
| Заказы: снимок, статусы, оформление (M6) | `modules/site/shop/src/Order*` |
| Оплаты: реестр провайдеров, ЮKassa (M6) | `modules/site/shop/src/{Payments,PaymentService}` |
| Импорт CommerceML 2 (M7) | `modules/site/catalog/src/Import` |
| Поиск по сайту (M7) | `src/Core/Search`, `catalog/src/Search` |
| SEO разделов с наследованием (M8) | `src/Core/Seo`, `*_create_section_seo_table` |
| Супер-админ платформы (M9) | `modules/platform/superadmin` |
| Персайтовый код и оверрайды (M9) | `src/Core/Site/Override`, `overrides/` |
| Фабрика сайтов: копия из шаблона (M9) | `src/Core/Site/Actions/CopySite` |
| Персайтовое подключение модулей (M9) | `src/Core/Module/ModuleAccess`, `*_create_site_modules_table` |
| Кабинет разработчика и панель редактора (M9) | `superadmin/OverrideFiles`, `Admin/Http/Middleware/InjectEditorOverlay` |
| Дамп сайта на консистентном снимке (M10) | `modules/platform/exporter` |
| Правила выгрузки от модулей (M10) | `src/Core/Export/ExportRules` |
| Урезание кода копии (M10) | `exporter/src/Code/{SurvivingModules,CodePackager}` |
| Медиа и паспорт выгрузки (M10) | `exporter/src/Media`, `exporter/src/ExportManifest` |
| Установщик автономной копии (M10) | `src/Core/Install` |
| Справочник городов РФ (M11) | `src/Core/Geo`, `database/data/cities-ru.json` |
| Состояние установки, `/health` (M11) | `src/Core/Health`, [docs/operations.md](docs/operations.md) |
| Русские тексты валидации (долг M6) | `lang/ru` |
| Почта сайта и письма о заказе (M11) | `src/Core/Mail/SiteMailer`, `shop/src/Notifications` |
| Восстановление пароля админки (долг M6) | `src/Core/Admin/PasswordResets`, `*_admin_password_resets_*` |
| Формы и заявки (M8) | `modules/site/forms` |
| JSON API v1 и Sanctum (M8) | `src/Core/Api`, `routes/api.php`, `*/routes/api.php` |
| Медиатека | `src/Core/Media`, `*_create_media_tables` |
| Баннеры (M11) | `modules/site/banners` |
| Боковые зоны, шаблоны меню, фон зон (M11) | `src/Core/Layout`, `themes/default/{row,zone,grid}.blade.php` |
| Конструктор карточки: раскладки, вкладки, колонка (M11) | `src/Core/Layout/{CardLayouts,CardLayoutWriter,ContentTypes}`, `catalog/src/Blocks` |
| Цвета зоны и блока (M11) | `src/Core/Layout/ColorStyles`, `public/kz-base.css` |
| Админка сайта из персайтового кода (M11) | `src/Core/Site/Override/{ExtendsAdminMenu,DeclaresSettings,DeclaresAdminRoutes}` |
| Свой CSS сайта, редактор кода (M11) | `src/Core/Http/{SiteStyles,Middleware/InjectSiteStyles}`, `Components/Fields/CodeField.vue` |
| Вкладки настроек, разворот на весь экран (M11) | `SettingGroup::asTabs`, `Components/{FullscreenFrame,Settings/SettingsTabs}.vue` |
| Тема по умолчанию | `themes/default` |
| Гейты | `deptrac.yaml`, `composer gate` |

## Ключевые решения и их причины

- **Таблица на компонент, а не единая objects+JSON.** В MySQL нет partial-индексов,
  поэтому индекс на `data->>'$.price'` в общей таблице покрывал бы и новости,
  и контакты. Поиск по любому полю требует настоящих колонок.
- **Цены, склады, характеристики — отдельные таблицы.** Легаси имел
  `price..price11`, `stock..stock10`, `param1..param15` фиксированными слотами.
- **Кэш на счётчиках версий, а не на тегах.** Автономная копия сайта работает
  на файловом драйвере, который тегов не умеет. `Cache::tags()` не использовать.
- **Middleware арендатора — глобальные, а не в группе `web`.** Иначе 404 не получит
  ни редиректа на основной домен, ни темы сайта.
- **Dev-домен `{login}.kzla.ru` никогда не редиректит на боевой** — иначе
  разработчик теряет доступ к сайту сразу после публикации.
- **`SiteContext` — scoped, не singleton**, и сбрасывается по `JobProcessing`:
  долгоживущий воркер иначе утащит данные одного арендатора в обработку другого.

- **Главная страница — обычный раздел** с пустым слагом и путём `/`, а не
  исключение через `Catalogue.Title_Sub_ID`, как в легаси.
- **`sections` несёт привязку к компоненту сама.** Правило «раздел = один
  компонент» делает связь 1:1, и отдельная таблица инфоблоков была бы join'ом
  на самом горячем пути. Прочие настройки компонента в разделе — в `settings`
  со `scope = section`.
- **Легаси-адрес `{слаг}_{id}.html` поддерживается только как источник 301.**
- **Ключ шаблона компонента уникален в пределах КОНТЕКСТА** (список, карточка,
  блок): у них разные выпадающие списки в админке, и «по умолчанию» там
  законно значит разные шаблоны.
- **Путь берётся из `$request->getPathInfo()`, а не из параметра маршрута** —
  роутер Laravel срезает завершающий слэш, и раздел уходил в вечный 301.
- **`Tests\TestCase` переопределяет `prepareUrlForRequest`:** штатный харнесс
  тоже срезает завершающий слэш, и разницу между `/catalog` и `/catalog/`
  было бы невозможно выразить в тесте.

- **Разбиения блоков по строкам в PHP нет.** Ширина блока едет в
  `grid-column: span N`, перенос делает браузер. `minmax(0, 1fr)` вместо `1fr`
  обязателен: иначе широкая таблица внутри блока распирает колонку.
- **Массовые операции обязаны идти через writer.** `Model::query()->delete()`
  не поднимает события Eloquent, и наблюдатель кэш не сбросит. Для правил
  видимости и порядка блоков есть `LayoutWriter`.
- **Memo в памяти запроса привязан к номеру версии кэша.** Иначе изменение,
  сделанное в том же запросе, не видно: счётчик инкрементирован, а объект
  отдаёт старое значение из памяти.
- **Шаблон списка раздела не годится для блока:** он печатает свой заголовок
  страницы. Для контекста блока объявляется отдельный шаблон через `block()`.

## Решения по админке (M3)

- **Админка на `/admin` домена сайта.** Арендатор уже определён резолвером
  хоста. Плата за это — сессия админки живёт на домене сайта; пересмотреть
  в M6 вместе с полноценной авторизацией.
- **Маршруты админки регистрирует провайдер ядра, а не `routes/web.php`.**
  Catch-all `/{path?}` иначе перехватил бы `/admin` и отдал 404 раздела.
- **Экранов настроек не пишется.** Один маршрут и один компонент строят форму
  из схемы модуля; пункт меню берётся из реестра схем.
- **Условия видимости считаются и на клиенте, и на сервере.** Правила
  приведения типов в JS повторяют PHP намеренно (`'0'` ложно): разойдись
  они — поле молча не сохранилось бы.
- **Клиенту не доверяем:** скрытое поле не валидируется и не сохраняется,
  чужие ключи отбрасываются, `span` обрезается по сетке зоны на сервере.
- **Секрет не уезжает в браузер**, пустое поле секрета означает «не менять».
- **`AdminMenu` — реестр**, наполняется лениво через `callAfterResolving`:
  схемы настроек регистрируются в `boot()` модулей, то есть позже ядра.
- **Путь медиа в базе относительный** (`img/3f/2a/{uuid}.webp`), имя файла —
  uuid, шардирование двумя уровнями hex, исполняемые расширения отклоняются.

## Решения по контенту (M4)

- **Один писатель объектов на все компоненты.** Модуль объявляет таблицу
  и поля; слаг, типы, медиа и порядок — забота ядра.
- **Системные колонки форма прислать не может** (`site_id`, `section_id`,
  `slug`): иначе подменой поля объект уехал бы в чужой раздел.
- **Слаг объекта не идёт вслед за названием** — адрес карточки живёт дольше
  заголовка. Но пишется всегда: он в уникальном ключе.
- **Компонент из конструктора неотличим от модульного** для реестра, писателя,
  резолвера и шаблонов. Ключ уникален в пределах сайта, модульный при
  совпадении выигрывает.
- **Значения полей конструктора — в типизированных колонках**, не в JSON:
  в MySQL нет partial-индексов, а числа должны сравниваться числами.
- **Картинки в текстах — относительными путями**, префикс приклеивает
  `@kzhtml` на выводе.

## Решения по каталогу (M5)

- **Ничего фиксированными слотами.** Цены, склады, характеристики и варианты —
  отдельные таблицы; легаси-`price2..price11` и `var1..var15` не воспроизводим.
- **Цена разрешается как настройка:** пользователь → группа → город → общая,
  плюс порог количества и срок действия.
- **Фильтр по характеристике — EXISTS по покрывающему индексу**, а не JOIN.
- **Счётчики фильтров — из витрины `catalog_facets`**, список товаров — всегда
  живой запрос.
- **`JOIN_ORDER(ps, catalog_products)` обязателен** — без подсказки оптимизатор
  сортирует все товары сайта: 1200 мс вместо 7.
- **Флаг публикации продублирован в `product_sections`** ради подсчёта
  для пагинации; синхронизирует наблюдатель товара.
- **Число товаров под фильтром кэшируется**: точный COUNT по 100 тыс. строк
  стоит ~80 мс, и дешевле его не сделать.
- **При селективном фильтре план разворачивается** от таблицы значений;
  ведущий фильтр выбирается по витрине фасетов, то есть бесплатно.
- **ЧПУ-фильтры — общий механизм ядра**: компонент отвечает, годится ли
  остаток пути (`acceptsPathTail`). О каталоге ядро не знает.

Гейт этапа пройден: 100 тыс. товаров, фасетная страница p95 124 мс.

## Текущий этап (M6 — закрыт)

Сделано: **персайтовые администраторы**. Таблица `admins` (`tenant`,
уникальность по `(site_id, email)`), роли владелец/администратор, экран учётных
записей, команда `admin:create`, отдельная кука сессии админки с путём `/admin`.
Платформенный пароль `KZ_ADMIN_PASSWORD` снят.

Решения этого шага:

- **Общего пароля платформы нет вообще.** Он открывал админку любого из сотен
  сайтов установки; первого владельца заводит консоль, дальше — сам владелец.
- **Ошибка входа одна на все случаи**, и bcrypt считается даже без учётки:
  иначе текст ошибки и время ответа выдают, какие адреса заведены.
- **Сессия помечена сайтом, учёткой и отпечатком пароля.** Отсюда: отключение
  учётки закрывает её сессии немедленно, смена пароля разлогинивает остальные
  устройства.
- **Memo гварда привязан к объекту запроса** — тот же приём, что с версией
  кэша. Без него scoped-объект пережил бы запрос, и вход на один сайт открывал
  бы админку соседнего (ровно эта ошибка и всплыла в тестах).
- **Правила «не остаться без доступа» живут в `AdminWriter`**, а не
  в контроллере: те же нужны консольной команде и фабрике сайтов.
- **Кука админки своя, но это не закрывает XSS** на домене сайта: скрипт
  дёрнет `/admin` тем же источником. Полная развязка — отдельный хост, M9.

Сделано: **покупатели сайта и вход по звонку** (порт из `teeu`). `users` стал
персайтовым, добавлены `user_phones`, `auth_identities`, `phone_verifications`.

Решения этого шага:

- **Уникальность всего — по паре с сайтом.** В `teeu` телефон был уникален
  глобально; здесь это заблокировало бы регистрацию на соседнем сайте и сломало
  бы восстановление персайтового дампа.
- **Свой поставщик учётных записей** (`SiteUserProvider`): сессия хранит только
  `users.id`, а он сквозной по установке. Без фильтра по сайту сессия одного
  сайта поднимала бы покупателя другого.
- **Состояние подтверждения — в базе, а не в сессии:** звонок оплачен,
  и перезагрузка страницы не должна его сжигать.
- **Провайдер дозвона — настройка сайта**, а не `.env`: у арендаторов разные
  договоры и балансы. Драйвер `log` отказывается работать в продакшене.
- **Отдельной страницы входа нет** — блок `auth` в шапке; форма работает
  без JavaScript.
- **Маршрут кэширует созданный контроллер** вместе с зависимостями: подмена
  привязки в контейнере между запросами внутри одного теста до второго запроса
  не доезжает. Двойники в тестах — живые объекты с изменяемым состоянием.

Сделано: **корзина** — модуль `shop`, зависящий от `catalog` (и только в эту
сторону: у каталога свой слой Deptrac, который не видит других модулей).

Решения этого шага:

- **Корзина в базе, а не в сессии**: собранная на телефоне находится
  на компьютере после входа. Гость опознаётся токеном в куке; при выходе токен
  перевыпускается, иначе на общем компьютере следующий увидит чужую корзину.
- **Статуса у корзины нет** — при оформлении позиции уедут в заказ снапшотом,
  а корзина очистится. Поэтому у покупателя корзина ровно одна, и это обычный
  уникальный ключ, а не partial-индекс, которого в MySQL нет.
- **`variant_id NOT NULL DEFAULT 0`**: два NULL в уникальном ключе MySQL
  не конфликтуют, и с nullable-колонкой корзина копила бы дубли одного товара.
- **Слияние берёт бо́льшее количество, а не сумму** — решение владельца.
- **Цена в корзине живая**, `price_at_add` — только отметка «цена изменилась».
- **Способ покупки — свойство ТОВАРА** (`cart` / `request` / `none`): грузовики
  в корзину не кладут, запчасти к ним кладут, и это один сайт. Кнопку рисует
  не каталог, а реестр `PurchaseActions`; без магазина товар просто без кнопки.
- **Персональные блоки не кэшируются** (`PersonalBlockContent`): у блока есть
  `cache_ttl`, и выставленный корзине он показал бы её содержимое всем.
- **Memo резолвера корзины привязан к запросу** — та же ошибка, что была
  с `CityContext` и гвардом админки.
- **`PageShell` в ядре**: `/cart` — адрес модуля, а не раздел, но выглядеть
  обязан как сайт. Тем же механизмом поедут оформление, кабинет и поиск.

Сделано: **заказы** (тот же модуль `shop`).

- **Позиция заказа — снимок, а не ссылка.** Название, артикул и цена копируются;
  `product_id` остаётся для отчётов, без каскадного удаления. В легаси заказы
  переписывались задним числом каждой выгрузкой из 1С.
- **Номер — персайтовый счётчик** через `INSERT … ON DUPLICATE KEY UPDATE
  value = LAST_INSERT_ID(value + 1)`: без блокировок и без гонок. Сквозной id
  выдавал бы обороты всей установки.
- **Статусы — справочник сайта, решения по флагам** (`is_paid`, `is_cancelled`,
  `is_final`): название придумывает владелец, код на него не смотрит.
  Стартовый набор заводится при первом обращении.
- **Подтверждение телефона идёт до подъёма корзины**: вход сливает гостевую
  корзину в корзину покупателя, и поднятая заранее ссылка указывала бы
  на удалённую.
- **`ShopException` вместо `RuntimeException`.** `QueryException` наследует
  `PDOException extends RuntimeException`, и контроллер показывал сбой базы
  как обычную неудачу формы. Нашлось живым прогоном, не тестами.
- **`--data-urlencode` в curl из Git Bash шлёт cp1251**, а не UTF-8: если
  живой прогон падает на `Incorrect string value`, дело в консоли, а не в коде.

Сделано: **оплаты**. Способ оплаты — справочник сайта поверх реестра
`PaymentProviders`; пустой провайдер = оплата при получении. Первый онлайн —
ЮKassa; ключи в настройках сайта, а не в `.env`.

- **Уведомлению банка не верим.** Подписи у ЮKassa нет, поэтому из тела берётся
  только идентификатор платежа, а статус и сумма перезапрашиваются у API. Иначе
  знающий адрес вебхука закрывал бы чужие заказы POST'ом.
- **Идемпотентность** — `UNIQUE (site_id, provider, external_id)` плюс ранний
  выход по `succeeded`: банк повторяет уведомление, пока не получит 200.
- **Фильтр по сайту в обработчике обязателен**: уведомление приходит на домен
  арендатора и не имеет права трогать заказ соседнего.
- **Сначала заказ, потом платёж**: платёж без заказа — деньги, которые некуда
  положить.
- **`Idempotence-Key` привязан к записи платежа**, а не ко времени: повтор
  при обрыве связи не должен стоить покупателю двух списаний.

Сделано: **доставка** — справочник способов сайта с расчётом за интерфейсом
(фиксированная цена с порогом бесплатной, самовывоз), цены по городам.
**Стоимость считается на сервере и никогда не берётся из формы**: иначе
покупатель пришлёт ноль. Адрес требует способ доставки, а не форма.

Сделано: **Яндекс ID и VK ID**. Authorization Code + PKCE, ключи приложения —
настройки сайта (адрес возврата содержит его домен и строится от хоста).
Порядок поиска учётки: удостоверение → подтверждённый телефон → почта, поэтому
входивший звонком попадает в ту же запись. Из профиля дописываются только
пустые поля: своё имя важнее провайдерского.

Сделано: **группы покупателей и персональные цены**. Справочник и привязка
живут в каталоге — группы существуют ради цен. `PriceResolver` сам спрашивает
группы текущего покупателя, если они не переданы явно: так цена
персонализируется везде, и забыть их передать больше нельзя.

⚠️ `postJson` в тестах НЕ отправляет куки без `withCredentials()` — из-за этого
слияние корзины при входе «не работало» в тесте, хотя в браузере работает.

Сделано: **личный кабинет** — «мои заказы» снимком, повтор заказа живой
корзиной, профиль без телефона (он ключ входа и меняется только звонком).
Гостю кабинет отдаёт 404, а не редирект: существование чужого кабинета —
тоже сведения.

**M6 закрыт.**

## Текущий этап (M7)

Сделано: **импорт CommerceML 2** — конвейер разбор → склад → слияние → зачистка
(`modules/site/catalog/src/Import`). Гейт пройден: 100 000 позиций, 3324 поз./с,
память 36 МБ константная, `kill -9` продолжается без дублей.

- **Слияние — один SQL-запрос на прогон**: `INSERT … SELECT … FROM import_stage
  … ON DUPLICATE KEY UPDATE` по `(site_id, external_id)`. Слаг считается
  на разборе и в список обновляемых колонок не входит: переименование в 1С
  не должно менять адрес карточки.
- **Зачистка — `UPDATE … LEFT JOIN`**, а не `NOT IN` на сто тысяч значений.
  Товары без `external_id` не трогаются: их 1С не присылала.
- **⚠️ `XMLReader::next()` уже стоит на следующем узле** — вызывать после него
  `read()` нельзя, теряется каждая вторая позиция. В пакете с переводами строк
  незаметно, в пакете без пробелов — половина товаров. Поймал тест.
- **Возобновление по `resume_offset`** плюс уникальный ключ склада
  `(run_id, external_id)`: даже разошедшийся счётчик не создаст дублей.

Сделано: **поиск по сайту** — общий индекс `search_index` (ngram), движок
за интерфейсом `SearchEngine`, индекс обновляется вместе с товаром.

- **`*` в конце слова с ngram — катастрофа**: запрос висел больше трёх минут.
  Парсер и так режет на биграммы, подстановка не нужна.
- **`chunk()` листает через OFFSET** — на сотой тысяче строк это 442 поз./с
  против 3252 у `chunkById`.
- **Тесты поиска нетранзакционные** (`DatabaseTruncation`): InnoDB обновляет
  полнотекстовый индекс при коммите, и внутри транзакции теста поиск не видит
  вставленных строк.
- ⚠️ **Гейт по поиску НЕ пройден на 100 тыс.**: два слова — 41 с. Естественный
  режим быстрее, но молча выбрасывает частые слова. Нужен Meilisearch;
  замеры — [docs/search.md](docs/search.md).

Сделано: **приёмник обмена** `/1c/exchange`. Разбор уходит в очередь: 1С ждёт
ответа секунды, а пакет разбирается минутами — легаси разбирал в запросе,
отсюда и висящие обмены.

- **Куски файла дописываются** (1С режет большой пакет), поэтому **`init`
  обязан чистить каталог** — иначе остаток прерванной выгрузки склеится
  с новой в мусор. Нашлось живым прогоном.
- **Один прогон на сайт за раз**, пока доступы не заданы — приёмник закрыт,
  имя файла из запроса чистится до `basename` и только `*.xml`.
- Живой прогон: 43 МБ тремя кусками, 100 000 позиций за 1 мин 22 с вместе
  с фасетами и переиндексацией.

Сделано: **индексация остального содержимого** — новости, страницы, объекты
любого компонента, включая собранный в конструкторе (`ObjectIndexer` вызывается
из `ObjectWriter`). Ремонт — `search:reindex {сайт}`.

- **Какие поля индексировать, объявляет компонент** (`->searchable()`), а не
  ядро. Иначе ядру пришлось бы знать, что у новости анонс, а у сотрудника
  должность, и каждый новый модуль правил бы ядро.
- **У компонента без карточки находка ведёт в раздел**, а не выпадает из поиска:
  привести в список лучше, чем спрятать содержимое.
- **Каталог из общей переиндексации исключён** — сто тысяч товаров через модели
  это сто тысяч запросов; у него своя пачечная команда.
- **Разметка снимается одним способом для всех** (`SearchIndexer::plain()`),
  и тег заменяется пробелом, а не пустотой: `strip_tags` склеивает
  `абзац</p><p>Второй` в одно слово. Выдержка раньше бралась из сырого значения
  — на каталоге не видно, описания там простым текстом. **Нашлось живым
  прогоном**, как и `ShopException`.

Сделано: **сторож импорта** (`catalog:import-watchdog`, ежечасно
в расписании, объявляет его сам модуль).

- **Чинит он не прогон, а сайт**: приёмник обмена не примет второй пакет, пока
  у сайта есть живой прогон, и убитый воркер запирал импорт навсегда.
- **Мёртвый опознаётся молчанием, а не временем старта.** Конвейер пишет прогон
  каждые 500 позиций — готовый пульс. Миллион позиций идёт час и жив; молчащий
  полчаса мёртв. Легаси резал по старту (два часа) и убивал живые выгрузки.
- **Порог в одном месте** (`ImportWatchdog::STALE_MINUTES`), им же пользуется
  приёмник. Прежнее «прогон моложе часа» било с обеих сторон: долгий обмен
  получал бы параллельный, а мёртвый держал сайт весь час.
- **`failed` — не «завершён», а «остановился»**: продолжить можно всё, кроме
  `done`. Команда печатала подсказку «продолжить», которая тут же отвечала
  «прогон уже завершён» — тесты этого не ловили, потому что `kill -9`
  оставляет статус `parsing`.
- **Склад успешного прогона удаляет сам конвейер.** Ничего его не убирало:
  на разработке от пяти прогонов лежало 302 500 строк. Склад упавшего живёт
  три дня — из него идёт `--resume`.

Сделано: **подсказки, подсветка, поиск в админке**.

- **Подсказки — по отдельному индексу заголовков** (`ft_title`). Не только ради
  скорости (106 с → 1,4 с на 100 тыс.): подсказка — переход к вещи, а вещь
  опознаётся названием. Совпадение в середине описания даёт подсказку, которую
  не связать с набранным.
- **В кэш едут массивы, а не объекты.** Сериализованный `SearchHit` приезжает
  обратно `__PHP_Incomplete_Class`. Тесты не видят: там драйвер `array`.
  **Нашлось живым прогоном.**
- **Выдержка режется вокруг совпадения**, метки ставятся ДО экранирования:
  искать «amp» в готовом HTML — значит подсветить половину мнемоник.
- **Поиск админки ходит в базу, а не в индекс**: в индексе только
  опубликованное, а в админку заходят за черновиком. Источники — реестр
  `AdminSearchProviders`, как `PurchaseActions` и `PaymentProviders`.
- **Однобуквенный запрос запрещён, кроме цифры**: буква — скан, цифра —
  точное попадание по номеру заказа.
- ⚠️ **Пустая вложенная `where(function …)` = отсутствие фильтра.** Провайдер
  заказов отдавал ВСЕ заказы сайта на любое слово. **Нашлось живым прогоном** —
  тест искал по номеру и телефону, то есть там, где условия есть.

Дальше: Meilisearch — отложен решением владельца.

## Текущий этап (M8)

Сделано: **SEO разделов с настоящим наследованием**. `section_seo`, значение
ищется ВВЕРХ по цепочке предков до первого установленного, последнее звено —
умолчание сайта из настроек.

- **Не копированием вниз.** Правка шаблона в корне каталога меняет одну строку,
  а не пятьсот, и не затирает разделы, где поле переопределили. Легаси копировал
  скриптом — отсюда «в подразделах старый заголовок».
- **Тумблер `inherit` управляет потомками, а не собой**: на своём разделе
  значение действует всегда.
- **Пустое поле = «наследовать»**, строка удаляется. Пустая строка оборвала бы
  цепочку, и вернуть наследование стало бы нечем.
- **Форма показывает только СВОИ значения**, унаследованное — подсказкой.
  Подставь его в поле — первое сохранение сделало бы копию, отвязанную
  от предка.
- **Умолчания сайта подмешиваются ПОСЛЕ кэша**: у настроек свой счётчик версий,
  а ключ помечен версией дерева.
- **Умолчания вообще нужны потому, что главная — не корень дерева**: она такой
  же раздел с `parent_id = NULL`, как «Каталог».
- **Мета-теги печатает платформа, а не тема** (`platform::head`): правило
  «корзину в поиск не пускать» не должно зависеть от верстальщика.
- ⚠️ **Раздел-контейнер отдавал целый HTML-документ внутрь блока** — второй
  `<title>` посреди страницы. Нашлось живым прогоном.

Сделано: **карта сайта и robots.txt**. Оба кодом, а не файлами: сайтов сотни,
а корень `public` один.

- **Карта — оглавление плюс файлы по 20 000 адресов**, отдаётся потоком мимо
  моделей. Живой прогон: 20 000 адресов, 3,1 МБ, 2,6 с.
- **Домен разработки и сайт-черновик закрыты целиком**, и это не настройка:
  копия боевого сайта в индексе конкурирует с оригиналом его же текстами.
- ⚠️ **`public/robots.txt` из скелета Laravel перекрывал маршрут.** Веб-сервер
  отдаёт файл раньше роутера, и на всех сайтах установки отвечало `Disallow:`
  — то есть «индексируй всё». **Нашлось живым прогоном.**

Сделано: **schema.org** (JSON-LD одним `@graph`).

- **Что описывать, знает компонент** (`ProvidesStructuredData`), а не ядро:
  товар — `Product`, новость — `NewsArticle`. Не реализует интерфейс —
  в разметку не попадает: пустой `Thing` роботу бесполезен.
- **Микроразметки `itemprop` нет намеренно**: она размазана по вёрстке клиента
  и ломается молча от любой правки шаблона.
- **Крошки — из дерева, а не из блока**: блока на странице может не быть.
- **Товар без цены не получает `offers`**: `price: 0` читается как «бесплатно».
  Персональная цена покупателя туда тоже не идёт — робот аноним.

Сделано: **формы и заявки** — модуль `forms`.

- **Заявка переживает форму**: ключ, название и подписи полей копируются
  снимком, `form_id` обнуляется вместо каскада. Форму переделают, а обращение
  человека обязано читаться через год.
- **Капчи нет**: она отсеивает и живых людей. Приманка (скрытое стилями поле)
  плюс время заполнения; **метка времени подписана HMAC** — иначе бот
  подставил бы время на минуту раньше.
- **Пойманный бот получает вид успеха**: узнав, что его поймали, он подберёт
  обход.
- **Письмо — дополнение, а не способ доставки**: сначала база, потом SMTP,
  и его сбой уходит в журнал. Потерять заявку из-за почты нельзя.
- **Телефон не проверяется маской**: отказ формы из-за скобок стоит дороже,
  чем заявка с телефоном в свободной форме.
- ⚠️ **`BlockView::get()` читает колонки блока, а не JSON `settings`.** Блок
  формы молча не находил свой ключ. Появился `BlockView::setting()`.

Сделано: **JSON API v1 и Sanctum**.

- **Токен помечен сайтом.** `users.id` и значение токена сквозные
  по установке: без метки токен одного арендатора поднимал бы покупателя
  у другого. Метку ставит сама модель токена, а чужой токен не находится
  вовсе — проверка в `findToken()`, а не в гварде, который можно забыть.
- ⚠️ **`sanctum.guard` очищен.** Штатное `['web']` пускает в API вошедшего
  на сайте вообще без токена: Sanctum сперва спрашивает сессию.
- **`UseApiGuard` переключает гвард по умолчанию** — иначе корзина, цены
  и группы покупателя молча отвечали бы как гостю.
- ⚠️ **Гость API получал 500 вместо 401**: `Authenticate` уводит на маршрут
  `login`, которого нет вовсе. **Нашлось живым прогоном.**
- **Телефон заказа берётся из учётки**, а не из запроса: он подтверждён
  звонком, и подменять его при оформлении — обесценить подтверждение.
- ⚠️ **Гварды в тестах живут в менеджере между запросами**: без
  `forgetGuards()` второй запрос поднимает покупателя первого, и проверка
  изоляции проходит вхолостую.

**M8 закрыт.**

## Текущий этап (M9)

Сделано: **супер-админ платформы** — модуль `modules/platform/superadmin`
(`tier = platform`, вырезается сборщиком выгрузки целиком).

- **Свой домен** (`KZ_PLATFORM_HOST`), маршруты ограничены им: иначе `/sites`
  открылся бы на каждом из сотен сайтов установки.
- **Хост платформы идёт мимо резолвера сайта**, и контекст арендатора
  чистится явно: scoped-объект пережил бы запрос предыдущего сайта,
  и платформенный экран уехал бы 301-м на чужую канонизацию.
- **`platform_admins` объявлена `global`, а не `tenant`.** Строка в персайтовой
  `admins` уехала бы клиенту вместе с выгрузкой его сайта — вместе с учёткой
  владельца платформы.
- **Первого супер-админа заводит консоль**: форма регистрации означала бы
  установку с открытой дверью до первого вошедшего.
- **Создание сайта — через то же `CreateSite`**, что и консоль: второй набор
  правил сборки сайта расходится с первым на второй же неделе.
- **Экраны на Blade**: у платформы три экрана, второй бандл Inertia дороже.

Сделано: **фабрика сайтов** — копия из шаблона (`CopySite`). Копируются
разделы, зоны, блоки с правилами, настройки, SEO и медиатека с файлами;
содержимое компонентов — по флажку.

- **Идентификаторы пересобираются по карте «старый → новый»**: у раздела
  `parent_id`, у блока `zone_id` и `source_section_id`, у настройки области
  «раздел» — `scope_id`. Без неё блок «объекты раздела» выводил бы новости
  сайта-шаблона: это не копия, а межарендаторная утечка. Проверяет отдельный
  тест «ничто в копии не смотрит в шаблон».
- **Домены, администраторы, покупатели, заказы и заявки не копируются**:
  устройство сайта — да, люди и их обращения — нет.
- **Каталог исключён**: сто тысяч товаров в шаблоне не нужны никому, а цены
  и характеристики — отдельный граф, дороже повторного импорта из 1С.
- **Заготовка от `CreateSite` стирается перед копированием**: оставленная
  рядом «Главная» дала бы сайт с двумя корневыми страницами.
- **Признака «это шаблон» нет**: им становится сайт, с которого копируют.

Сделано: **персайтовое подключение модулей** (`site_modules`, `ModuleAccess`).

- **Отключаемым модуль объявляет себя сам** (`optional` в `module.json`).
  Ядро и то, без чего сайта нет, не отключается: иначе владелец оставил бы
  себя без главной страницы одним переключателем.
- **Нет строки — значит «как заведено»**, а не «выключено»: иначе новый модуль
  требовал бы вставки строки каждому из сотен сайтов, и пропуск ломал бы сайт
  молча.
- **Отключение = 404 на адресах модуля**, а не «модуль отключён»: для
  посетителя это несуществующий адрес, а не запертая дверь.
- ⚠️ **Проверка модуля идёт РАНЬШЕ авторизации**: иначе гость получал 401
  вместо 404 — система предлагала войти ради адреса, которого нет.
  Ориентир в списке приоритетов — контракт `AuthenticatesRequests`, а не класс
  `Authenticate`: ссылка на класс молча не находит куда вставлять.
- ⚠️ **Реестры помнят ключ модуля**: регистрация идёт при загрузке, когда сайт
  неизвестен. **Живой прогон поймал**, что адреса корзины уже 404, а каталог
  всё ещё рисует кнопку «в корзину».
- ⚠️ **Inertia отдаёт props JSON-ом с юникод-экранированием** — `assertSee('Заказы')`
  в админке не найдёт ничего никогда. Проверять по ключам пунктов.

Сделано: **персайтовый код** (`SiteOverrideBooter`, `overrides/{ключ}/Booter.php`).

- **Один вход и явный контракт** вместо легаси-`/b/{login}`, где php-файлы
  подключались в произвольных точках шаблона. Никакого `eval`.
- ⚠️ **Резолвер страниц стал фолбэком.** Обычный catch-all `/{path?}` перехватывал
  адрес раньше всего, что объявлено позже, и персайтовый маршрут не работал.
  Фолбэк матчится последним — это и есть роль резолвера дерева.
- ⚠️ **Маршрут переопределения привязан к своему сайту**: таблица роутера живёт
  столько же, сколько процесс, и адрес одного арендатора отвечал бы на домене
  другого. Нашлось тестом.
- **Сбой переопределения не роняет установку**: в продакшене в журнал,
  на разработке — исключением. Молчаливое проглатывание = часы поиска.
- ⚠️ **В тестах у каждого переопределения свой ключ**: PHP не переопределяет
  уже загруженный класс, и второй тест получил бы тело `Booter` первого.

Сделано: **кабинет разработчика** — правка персайтового кода из браузера.

- **Только супер-админ платформы.** Редактор пишет PHP на сервер: отдай его
  владельцу сайта — и клиент с визиткой получит доступ ко всем 299 соседям.
- **Путь нормализуется и сверяется с корнем**: имя файла приходит из формы,
  и `../../.env` записался бы так же охотно, как `Booter.php`.
- **Синтаксис проверяется `token_get_all(TOKEN_PARSE)` ДО записи** — он бросает
  `ParseError` и ничего не исполняет. Файл с опечаткой кладёт сайт целиком.
- **Blade так не проверяется**: `@if` для парсера PHP — просто текст.

Сделано: **панель редактора на живом сайте**. Ссылка на правку открытого
раздела, переход в зоны и блоки, подсветка блоков.

- **Вставляется middleware в готовый ответ**, а не темой: работает на любой
  вёрстке и не попадает в кэш блоков.
- ⚠️ **У панели своя кука-метка, а не сессия админки**: та живёт с путём
  `/admin` и на страницах сайта не передаётся вовсе. Метка прав не даёт —
  по ней рисуется панель, действия всё равно уходят на `/admin`.
- ⚠️ **Метку расшифровывает сам middleware**: `EncryptCookies` в списке
  приоритетов Laravel, а панель — нет, и порядок оказывается не тем, которым
  его объявили. Значение приходило зашифрованным, панель молча не рисовалась.

**M9 закрыт.**

## Текущий этап (M10)

Сделано: **дамп базы на консистентном снимке** — модуль
`modules/platform/exporter` (`tier = platform`), команда `site:export`.

- **Снимок, а не сотня разрозненных SELECT'ов.** `REPEATABLE READ` +
  `START TRANSACTION WITH CONSISTENT SNAPSHOT`: выгрузка сотни таблиц идёт
  минутами, и без общего снимка заказ уехал бы без своих позиций, а товар —
  без цен. Никого при этом не блокирует: InnoDB отдаёт старые версии строк.
- **Соединение своё, не приложения.** Иначе `START TRANSACTION` оказался бы
  вложенным (приложение уже в транзакции), потоковое чтение сломало бы соседние
  запросы, а запись приложения попала бы внутрь снимка.
- ⚠️ **Тесты выгрузки нетранзакционные** (`DatabaseTruncation`, как у поиска):
  второе соединение видит только зафиксированное, под `RefreshDatabase` дамп
  доказывал бы пустоту.
- ⚠️ **Первый живой прогон отправил клиенту учётку владельца платформы.**
  `platform_admins` была `global`, а это означает «справочник, который копии
  нужен, и он едет целиком». Появилась пятая категория `platform` — «не едет
  вовсе, ни строк, ни схемы». Инвариант-тест теперь читает миграции
  `modules/platform/*` и требует её для каждой их таблицы.
- **`ignore` уточнён: схема едет, строки — нет.** Копии нужна таблица `jobs`
  (на хостинге без Redis очередь идёт в базу) и `sessions`; миграция, которая
  их создаёт, там уже отмечена выполненной. А `migrations` едет со строками:
  без них копия при первом обновлении прогнала бы все миграции заново.
- **Идентификаторы не перенумеровываются.** Перенумерация — это обход всех
  ссылок между таблицами, то есть ровно то место, где чужая строка уезжает
  в чужой сайт. `AUTO_INCREMENT` вырезается из `CREATE TABLE` (там счётчик всей
  установки) и доводится футером до максимума выгруженных строк; максимум
  считается на лету из самих строк, без отдельного `SELECT MAX`.
- ⚠️ **Значения читаются строками** (`ATTR_STRINGIFY_FETCHES`): драйвер иначе
  приводит колонки к типам PHP, и `DECIMAL(18,2)` приезжает `float`, теряя
  копейки. **Нашлось живым прогоном** (первый упал на `int` вместо `?string`).
- **Часовой пояс снимка `+00:00` и он же в шапке дампа** — иначе даты заказов
  сдвинутся на разницу поясов платформы и хостинга клиента.
- **Внешние ключи при восстановлении отключены**: в схеме 93 связи, среди них
  взаимные, и порядка «каждая таблица после той, на которую ссылается»
  не существует в принципе.
- **Тест «дамп восстанавливается в чистую базу»** дороже проверки подстрокой,
  но закрывает разом экранирование, порядок таблиц и внешние ключи.

Живой прогон `medtehnika`: 200 116 строк в 74 таблицах, 87,5 МБ, 6,6 с, пик
памяти 34 МБ (на сайте из 49 строк — 32 МБ, то есть константный).
Восстановление в чистую базу 47 с, счётчики сошлись, `platform_admins`
в копии нет вовсе. Подробности — [docs/export.md](docs/export.md).

Сделано: **правила выгрузки от модулей** — реестр `ExportRules`
(`src/Core/Export`), наполняется в `bootModule()` рядом с объявлением таблиц.

- **Правило означает «едет пустой», а не «таблицы не будет».** Код модуля
  в копии есть, и отсутствующая физически `import_stage` уронила бы первый же
  обмен с 1С. Совсем не едут только таблицы платформенных модулей — это
  категория `platform`.
- **Поисковый индекс не выгружается**: 58% размера дампа при том, что строится
  из содержимого, которое едет рядом. Дамп 87,5 → 36,8 МБ, восстановление
  47 → 24 с.
- **История прогонов импорта не выгружается не ради размера**: выгрузка,
  сделанная во время импорта, увезла бы прогон в статусе `parsing`, и приёмник
  обмена в копии не принял бы ни одного пакета, пока сторож не признал бы
  его мёртвым.
- **Модуль объявляет и то, чем невыгруженное достраивается** (`restoreWith`):
  ядро не знает, что индекс каталога строится отдельной командой от индекса
  остального содержимого, и не должно узнавать — иначе каждый новый модуль
  правил бы установщик.
- **Отчёт всегда говорит, что уехало пустым и почему.** Молчаливое урезание
  читается как «выгрузили всё». Последнее слово за оператором: `--keep`.
- **Интерфейса `ExportContributor` из плана не получилось**: контракт свёлся
  к двум объявлениям там же, где модуль регистрирует таблицы, — это реестр,
  а не контракт.

Сделано: **урезание кода** — `SurvivingModules` + `CodePackager`
(`modules/platform/exporter/src/Code`). Выгрузка теперь отдаёт готовый корень
копии: код, дамп и `.env.example`.

- **Копируется разрешённое, а не всё кроме запрещённого.** Список того, что
  нельзя отдавать клиенту, невозможно держать полным: завтра появится каталог
  с ключами интеграций, и запрещающий список о нём не узнает. Забытый
  разрешающий ломает копию заметно, забытый запрещающий не ломает ничего.
- **Не уезжают:** `.env`, содержимое `bootstrap/cache` (в `config.php` запечён
  весь `.env`, в кэше модулей — абсолютные пути платформы), `modules/platform`,
  **чужие `overrides` и `themes`** (это код и вёрстка других клиентов),
  содержимое `storage`, `tests`/`docs`/`node_modules`.
- **Зависимости выжившего модуля возвращаются в набор**, даже если сайт их
  отключил: магазин без каталога — это копия, которая не поднимается вовсе.
- **Режим `single` и файловый кэш — в сгенерированном `.env.example`**:
  мультисайтовые ветки убираются конфигом, а не правкой исходников.
- **Автозагрузчик пересобирается обязательно**: `composer.json` классмапит
  `overrides`, и классмап платформы содержит имена файлов персайтового кода
  ВСЕХ клиентов установки.
- ⚠️ **`composer dump-autoload --no-scripts`**: `post-autoload-dump` у Laravel
  запускает `package:discover`, то есть поднимает приложение копии, а у неё
  ещё нет ни `.env`, ни `APP_KEY`. Без ключа composer падает с невнятным
  «returned with error code 1». **Нашлось живым прогоном.**

Живой прогон `probnyj`: 12 047 файлов, 91 МБ, 145 с. Проверка из плана —
`grep` по копии на пространство имён платформенных модулей — пусто.

Сделано: **медиа и `EXPORT.json`** (`exporter/src/Media`, `ExportManifest`).

- **Ни одной строки базы не переписывается** — ради этого и заведён второй
  инвариант. Проверяется он ДО сборки: абсолютный путь или логин первым
  сегментом в `media.path` останавливают выгрузку. Копия с такими путями —
  это сайт без единой картинки, и выясняется это у клиента.
- **Здесь список запрещающий, в отличие от кода**, и это не непоследовательность:
  в коде лишний файл — утечка чужого, в медиа недостающий — потерянная картинка
  клиента. Не едут только `imp` (пакет прошлого обмена, 42 МБ на живом сайте)
  и `cache` (производные).
- **Суммы — отдельным файлом `media.sha256`** в формате `sha256sum`, а не
  массивом в JSON: у сайта с сотней тысяч картинок массив сам стал бы
  мегабайтами, которые нечем проверить. Клиент сверяет штатной утилитой.
- **Режим `manifest`** для медиатек на десятки гигабайт: суммы едут, файлы
  доставляются отдельно.
- **`EXPORT.json` читает установщик, а не человек**: по нему он знает, какие
  таблицы пусты намеренно и чем их достроить. **Шаги достройки фильтруются
  по уехавшим модулям** — копия без каталога не должна получить указание
  перестроить его индекс командой, которой в ней нет.

Сделано: **установщик копии** — `korzilla:install` (`src/Core/Install`).

- **Команда в ядре, а не в платформенном модуле**: выполняется она в копии,
  откуда платформенные модули вырезаны.
- **Дамп разбирается на операторы разборщиком с состояниями**, а не
  `explode(';')`: в дампе едут тексты страниц, и точка с запятой посреди текста
  разрезала бы оператор пополам. Читается кусками — 36 МБ в `memory_limit=128M`
  не поднять.
- **Всё после записи `.env` идёт отдельными процессами**: текущий поднялся
  до появления конфигурации и ходил бы не в ту базу.
- **Секреты настроек в копию не едут** (`ExportRules::withoutSecrets`): они
  зашифрованы ключом ПЛАТФОРМЫ. Либо перешифровывать — и тогда платформа знает
  ключ копии, — либо не везти. Не везём: владелец вводит ключи заново, и дамп
  перестаёт быть файлом с ключами от кассы.
- ⚠️ **Приложение не поднималось без `APP_KEY` вовсе.** Магазин и ядро
  резолвили реестры в `boot()`, те тянули настройки, настройки —
  шифровальщик. Копия не могла выполнить ни одной команды до того, как ключ
  появится, а появиться он должен был как раз командой. Лечение: шифровальщик
  приходит в `SettingsRepository` замыканием, регистрации идут через
  `callAfterResolving`. **Побочно исправлена вторая беда**: реестр кнопок
  покупки — scoped, его сбрасывает `forgetScopedInstances()` перед каждой
  задачей очереди, и регистрация при загрузке до второй задачи не дожила бы.
  И `key:generate` на свежем клоне теперь работает.

**Гейт этапа пройден локально:** `medtehnika` (100 001 товар) выгружен, поднят
на чистой базе с файловым кэшем и очередью в базе, смоук-набор зелёный —
главная, разделы, карточка товара, поиск, корзина, вход в админку, карта сайта.
`/sites` и `/superadmin` отвечают **404: раздела нет, а не скрыт**. Остаётся
прогон на настоящем хостинге клиента.

## Где остановились

**M0 — M9 закрыты, M10 собран целиком, начат M11.** Выгрузка отдаёт готовый
корень копии (дамп на снимке, правила модулей, урезанный код, медиа,
`EXPORT.json`), а `korzilla:install` поднимает её на чистой базе. Смоук-набор
по поднятой копии зелёный; остаётся прогон на настоящем хостинге.

Из M11 сделано: **регламент эксплуатации** ([docs/operations.md](docs/operations.md))
и **`/health`** — 200 или 503 для внешнего наблюдателя, подробности только
по токену. Очередь считается больной по возрасту старшей задачи, а не по длине:
длинная очередь — это нагрузка, старая — умерший воркер. Остальное в M11
упирается в боевой сервер: нагрузочный тест, ротация журналов, проверка
бэкапов восстановлением.

Из M11 сделано по замечаниям со стенда `test.krzx.ru`: баннеры, показ
подразделов, боковые зоны, шаблоны меню, **конструктор карточки из блоков**,
цвета зоны и блока, **админка сайта из персайтового кода**. Список со ссылками —
[docs/roadmap.md](docs/roadmap.md#m11--первый-живой-сайт--текущий). Следующее
по админке — страница Blade внутри оболочки админки для экранов клиента.

Решения и уроки этого шага:

- **Раскладка — сущность** (`layouts`: `site` одна, `card` сколько угодно),
  и раскладка сайта — тоже строка, а не «зона без раскладки». Любой запрос зон
  фильтрует по `layout_id`, иначе цена товара высыпалась бы в шапку.
- **Шаблон карточки общий по умолчанию**, свой — только где карточка правда
  другая. Наследуется вверх по дереву, как SEO. Нет шаблона или он пуст —
  рисует Blade-шаблон компонента, поэтому ни один сайт не сломался.
- **Вкладки и колонка — вложенностью блоков** (`parent_id`, глубина одна),
  а не галочками: «Доставка» во вкладке не требует кода. Выключенный контейнер
  уносит детей с собой.
- ⚠️ **Ключ кэша блока карточки содержит объект**, иначе первый открытый товар
  раздаёт свою цену всем. Проверено снятием патча.
- ⚠️ **Scoped-объект переживает запрос** везде, где его не сбрасывают явно,
  в том числе в тестах. Меню админки поэтому `bind`, а не `scoped`: тест изоляции
  поймал меню одного клиента у соседа.
- ⚠️ **`where('col', null)` — это `= NULL`**, не совпадает ни с чем. Общие
  шаблоны выпадали бы из проверки «умолчание ровно одно»; нужен `whereNull`.
- ⚠️ **Зона бывает внутри зоны** — правила «стакана» только дочерним селектором.
  Вложенный обнулял зазоры сеткам карточки.
- ⚠️ **Форма шлёт все поля, тесты — только проверяемые.** `layout_id: null`
  из формы зоны затирал колонку, и любая правка зоны отвечала 500 — тесты этого
  не видели. Тест теперь повторяет запрос браузера целиком.
- ⚠️ **Персайтовое в общих реестрах помечается владельцем.** Меню, схемы
  настроек и маршруты переопределения спрашиваются по ключу текущего сайта;
  ключи настроек клиента — с префиксом переопределения; адреса админки
  клиента — только через `adminRoutes()`, иначе они без проверки входа.
- ⚠️ **Хост платформы отвечает только объявленным для него.** Маршруты сайта
  без привязки к хосту давали на нём 500 — ~150 ошибок в сутки от ботов,
  нашлось по журналу стенда. Список разрешающий.
- ⚠️ **Текст подсказок настроек не показывался ни у одной** с M6: его
  объявляли `default()`, а форма печатает `hint`. Теперь текст — третий
  аргумент `note()`, а `default()` у подсказки бросает исключение. Нашлось
  по скриншоту: форму не рисует ни один тест.
- ⚠️ **Развёрнутая на весь экран рамка держит своё место заглушкой**, иначе
  страница становится короче экрана и прокрутка сбрасывается в ноль. Нашлось
  прогоном собранного бандла с поддельным ответом Inertia — тесты jsdom
  раскладки не считают.

У M10 **жёсткий гейт**: выгруженный реальный сайт поднимается на обычном
php/mysql-хостинге и проходит smoke-тесты. Проверять в том числе, что разделы
SUPERADMIN и создание сайта **отсутствуют, а не скрыты**.

Ради этого гейта уже сделано: `TenantTables` с категориями, `tier=platform`
у платформенных модулей и запрет Deptrac на ссылки из site-модулей,
относительные пути медиа, кэш на счётчиках версий (файловый драйвер тегов
не умеет), `platform_admins` вне персайтовых таблиц.

Отложено сознательно:

| Что | Почему |
|---|---|
| Meilisearch | решение владельца; гейт поиска на 100 тыс. остаётся непройденным |
| Биллинг маркетплейса | обсуждается отдельно, к функциям CMS не относится |
| Чеки 54-ФЗ | вместе с первым живым магазином |

### Состояние базы разработки

Живые прогоны оставили в `korzillax` мусор: супер-админа платформы, сайты
`probnyj`, `kopiya`, `kopiya2`, форму «Обратная связь» с блоком в подвале
`medtehnika` и одну заявку, SEO-шаблоны на разделах 1 и 5. Всё это можно
удалять: боевых данных в базе разработки нет.

В `storage/app/sites/medtehnika` лежат четыре файла-пустышки (`img/a1…`,
`doc/price.pdf`) без строк в `media` — они заведены руками, чтобы прогон
выгрузки медиа было на чём проверять.

Пароли учёток разработки в репозиторий не пишутся — они в памяти сессии
и в самой базе.
