# Схема базы данных

MySQL 8, InnoDB, `utf8mb4`. Все сайты в общих таблицах, разделение по `site_id`.

## Реестр таблиц

Каждая таблица объявлена в `TenantTables` — это не документация, а рабочий
механизм: тем же реестром пользуется сборщик выгрузки сайта, чтобы понять, что
выгружать по `WHERE site_id = ?`, что целиком, а что не выгружать вовсе.

| Категория | Смысл | Выгрузка |
|---|---|---|
| `root` | первичный ключ = идентификатор сайта | `WHERE id = :site` |
| `tenant` | строки принадлежат сайту | `WHERE site_id = :site` |
| `global` | платформенный справочник | целиком |
| `ignore` | инфраструктура | схема без строк |
| `platform` | данные самой платформы | не выгружается вовсе |

Разница между `ignore` и `platform` не косметическая: копии **нужна** таблица
`jobs`, чтобы принимать очередь, и не нужны задания платформы, — а вот
`platform_admins` не нужна ни строками, ни схемой. Разбор —
[export.md](export.md#что-уезжает-а-что-нет).

Объявление живёт в провайдере модуля:

```php
$this->app->make(TenantTables::class)->tenant('news_items');
```

`TenantTableInvariantTest` проверяет в обе стороны: у объявленных таблиц
колонка существует, и ни одна таблица в базе не забыта в реестре.

## Текущее состояние

| Таблица | Категория | Узел |
|---|---|---|
| `sites` | root | [multitenancy](multitenancy.md) |
| `site_domains` | tenant | [multitenancy](multitenancy.md) |
| `settings` | tenant | [settings](settings.md) |
| `settings_audit` | tenant | [settings](settings.md) |
| `sections` | tenant | [structure](structure.md) |
| `redirects` | tenant | [structure](structure.md) |
| `layouts` | tenant | [layout](layout.md#карточка-объекта-из-блоков) |
| `zones` | tenant | [layout](layout.md) |
| `blocks` | tenant | [layout](layout.md) |
| `block_section_rules` | tenant | [layout](layout.md) |
| `block_city_rules` | tenant | [layout](layout.md) |
| `block_page_rules` | tenant | [layout](layout.md#служебные-страницы) |
| `media` | tenant | [admin](admin.md#медиатека) |
| `mediables` | tenant | [admin](admin.md#медиатека) |
| `component_definitions`, `component_fields` | tenant | [components](components.md#компоненты-созданные-в-админке) |
| `component_objects`, `component_values` | tenant | [components](components.md#компоненты-созданные-в-админке) |
| `cities` | tenant | [geo](geo.md) |
| `geo_cities` | global | [geo](geo.md) — справочник РФ, 1111 строк |
| `admins` | tenant | [admin](admin.md#вход) |
| `users`, `password_reset_tokens` | tenant | [auth](auth.md) |
| `user_phones`, `auth_identities`, `phone_verifications` | tenant | [auth](auth.md) |
| `catalog_products`, `product_sections`, `product_variants`, `product_relations`, `catalog_brands` | tenant | [catalog](catalog.md) |
| `catalog_attributes`, `catalog_attribute_options`, `product_attribute_values`, `catalog_facets` | tenant | [catalog](catalog.md) |
| `price_types`, `product_prices`, `user_groups`, `user_group_members`, `warehouses`, `product_stocks` | tenant | [catalog](catalog.md) |
| `import_runs`, `import_stage` | tenant | [import](import.md) |
| `search_index` | tenant | [search](search.md) |
| `carts`, `cart_items` | tenant | [shop](shop.md) |
| `orders`, `order_items`, `order_events` | tenant | [shop](shop.md) |
| `order_statuses`, `number_sequences` | tenant | [shop](shop.md) — у статуса есть флаг `notify_customer` |
| `payment_methods`, `order_payments` | tenant | [shop](shop.md#оплата) |
| `delivery_methods`, `delivery_prices` | tenant | [shop](shop.md#доставка) |
| `section_seo` | tenant | [seo](seo.md) |
| `forms`, `form_fields`, `form_submissions` | tenant | [forms](forms.md) |
| `personal_access_tokens` | tenant | [api](api.md) |
| `site_modules` | tenant | [platform](platform.md#подключение-модулей-сайту) |
| `platform_admins` | **platform** | [platform](platform.md) |
| `admin_password_resets` | tenant | [admin](admin.md#забыли-пароль) — токен хранится хэшем |
| `news_items` | tenant | модуль `news` |
| `text_pages` | tenant | модуль `textpage` |
| `migrations`, `cache`, `cache_locks`, `jobs`, `job_batches`, `failed_jobs`, `sessions` | ignore | инфраструктура Laravel |

## Ключевые таблицы

### sites

```
id, login UNIQUE, name, status, theme, override_key,
locale, timezone, plan, primary_domain_id, timestamps, deleted_at
```

`login` — ключ арендатора: имя каталога данных и основа девелоперского домена.
`override_key` отвязан от `login`: логин может смениться, каталог
переопределений при этом переезжать не должен.

### site_domains

```
id, site_id, host UNIQUE(ascii_bin), kind ENUM(primary|alias|dev),
force_https, www ENUM(keep|add|strip), redirect_to_primary, is_active
KEY (site_id, kind, is_active)
```

`host` в `ascii` — хост хранится в punycode, и уникальный индекс остаётся
компактным.

### sections

```
id, site_id, parent_id,
path VARCHAR(400) ascii_bin, slug VARCHAR(160) ascii_bin,
name, alt_name, component_key, template_key,
card_layout_id,                           ← шаблон карточки; NULL — как у родителя
depth, sort_order, is_published, in_menu, is_system,
external_url, open_in_new_tab, objects_count

UNIQUE (site_id, path)                    ← резолв URL: одно попадание
UNIQUE (site_id, parent_id, slug)         ← слаги не сталкиваются у соседей
KEY (site_id, parent_id, sort_order)      ← дети раздела для меню
KEY (site_id, is_published, in_menu)
KEY (site_id, component_key)
```

`path` в `ascii`: слаги генерируются транслитом, кириллицы там не бывает.

### redirects

```
id, site_id, from_path VARCHAR(400) ascii_bin, to_path,
status, is_wildcard, source ENUM(manual|rename|import), is_active, hits

UNIQUE (site_id, from_path)
KEY (site_id, is_wildcard, is_active)
```

### settings

```
id, site_id, scope, scope_id, key,
value_string, value_int, value_decimal, value_bool, value_datetime, value_text,
is_encrypted, updated_by

UNIQUE (site_id, scope, scope_id, key)
KEY (site_id, scope, scope_id)
```

### admins

```
id, site_id, email, name, password, role ENUM(owner|admin),
is_active, last_login_at, last_login_ip, timestamps

UNIQUE (site_id, email)
KEY (site_id, is_active)
```

Уникальность почты — по паре с сайтом, а не глобально: агентство ведёт
несколько сайтов с одной почтой, и глобальный ключ сломал бы восстановление
персайтового дампа на общей установке. Подробности — [admin.md](admin.md#вход).

### Покупатели

```
users                   id, site_id, name?, email?, password?, email_verified_at,
                        consent_accepted_at, is_active, last_login_at, last_login_ip
                        UNIQUE (site_id, email)
                        KEY (site_id, is_active)

user_phones             id, site_id, user_id, phone(ascii_bin), is_primary, verified_at
                        UNIQUE (site_id, phone)
                        KEY (site_id, user_id, is_primary)

auth_identities         id, site_id, user_id, provider, provider_user_id,
                        provider_phone, metadata JSON
                        UNIQUE (site_id, provider, provider_user_id)

phone_verifications     id, site_id, phone, code_hash, provider_ref,
                        attempts, ip_address, expires_at, consumed_at
                        KEY (site_id, phone, consumed_at)
```

Имя, почта и пароль необязательны: вход по звонку не спрашивает ничего, кроме
телефона. Уникальность **везде по паре с сайтом** — номер с одного сайта
не должен блокировать регистрацию на другом. Подробности — [auth.md](auth.md).

### layouts

```
id, site_id,
kind ENUM(site|card),        site — страница сайта, card — шаблон карточки
component_key,               у карточки: чьей, NULL — общий для всех типов; у страницы NULL
name, is_default

KEY (site_id, kind, component_key)
```

Раскладка сайта — тоже строка этой таблицы, а не «зона без раскладки»: NULL
в значении «та самая, главная» — второй способ сказать то же самое, и первый
же запрос, забывший про него, смешал бы зоны карточки с зонами страницы.

### zones

```
id, site_id, layout_id, key_name, name,
band ENUM(above_content|content_left|content|content_right|below_content|footer),
sort_order,
is_system, is_enabled, is_header, is_footer, fix_on_scroll,
container, container_width, columns, gutter_x, gutter_y, align,
… геометрия, цвета, фон, флаги наследования для блоков …

UNIQUE (site_id, layout_id, key_name)
KEY (site_id, is_enabled, band, sort_order)
```

Ключ зоны уникален внутри РАСКЛАДКИ: «шапка» карточки и шапка сайта — разные
зоны с одинаково очевидным ключом.

### blocks и правила

```
blocks
    id, site_id, zone_id,
    parent_id,                  контейнер (вкладки, колонка); NULL — блок сам по себе
    content_type,
    span, span_tablet, span_mobile, align_x, align_y, sort_order,
    source_section_id, source_object_id, template_key, items_limit, …
    bg_color, text_color, link_color, icon_color,
    button_bg_color, button_text_color, heading_color,
    pin_all_pages, visibility_mode, hide_on_object_pages,
    pages_allow, pages_deny, noindex, cache_ttl, settings JSON

    KEY (site_id, zone_id, is_enabled, sort_order, id)
    KEY (site_id, pin_all_pages, is_enabled)
    KEY (site_id, source_section_id)

block_section_rules
    id, site_id, block_id, section_id, mode ENUM(allow|deny), cascade
    UNIQUE (block_id, section_id, mode)
    KEY (site_id, section_id, mode)

block_city_rules
    id, site_id, block_id, city_id, mode

block_page_rules                       корзина, оформление, кабинет, поиск
    id, site_id, block_id, page_key, mode
    UNIQUE (block_id, page_key, mode)
```

### Медиа

```
media
    id, site_id, uuid, disk, path, name,
    mime, extension, size, width, height,
    folder, alt, title, deleted_at

    UNIQUE (site_id, uuid)
    KEY (site_id, folder, id)
    KEY (site_id, mime)

mediables
    id, site_id, media_id, mediable_type, mediable_id, collection, sort_order
    UNIQUE (media_id, mediable_type, mediable_id, collection)
    KEY (site_id, mediable_type, mediable_id, collection, sort_order)
```

`path` — путь **относительно каталога данных сайта**: `img/3f/2a/{uuid}.webp`.
Логина арендатора в нём нет: его подставляет диск Laravel. В легаси
`Multifield.Path` содержал `/a/{login}/files/multifile/…` в 1.75 млн строк,
и переименование сайта означало переписывание их всех.

Имя файла на диске — uuid, а не оригинальное имя: кириллица, пробелы и двойные
расширения в путь не попадают. Оригинальное имя лежит в `name`.

Привязки вынесены в `mediables`, потому что один файл может стоять и обложкой
раздела, и картинкой блока, и не должен из-за этого копироваться на диске.

### Конструктор компонентов

```
component_definitions   id, site_id, key_name, name, has_detail_page, is_enabled
                        UNIQUE (site_id, key_name)

component_fields        id, site_id, definition_id, key_name, label, kind,
                        флаги, options JSON, rules JSON, group_key, sort_order
                        UNIQUE (definition_id, key_name)

component_objects       id, site_id, definition_id, section_id, slug, name,
                        is_published, sort_order
                        UNIQUE (site_id, section_id, slug)

component_values        id, site_id, object_id, field_id,
                        value_string | value_int | value_decimal |
                        value_bool | value_datetime | value_text
                        UNIQUE (object_id, field_id)
                        KEY (site_id, field_id, value_string, object_id)
                        KEY (site_id, field_id, value_int, object_id)
                        KEY (site_id, field_id, value_decimal, object_id)
```

Значение лежит в колонке своего типа, а не в JSON: в MySQL нет partial-индексов,
и индекс по `data->>'$.price'` покрывал бы значения всех полей всех компонентов
сразу. Индексы значений покрывающие — выборка «объекты, у которых поле = X»
не доходит до таблицы.

JSON здесь есть только в `options` и `rules`: это описание самого поля,
по которому не ищут.

### Таблицы компонентов

Каждый компонент-модуль владеет своей типизированной таблицей:

```
news_items
    id, site_id, section_id, slug, name, anons, body,
    published_at, source_url, is_published, is_featured, sort_order

    UNIQUE (site_id, section_id, slug)
    KEY (site_id, section_id, is_published, published_at)   ← лента по дате
    KEY (site_id, is_featured, published_at)
```

Индекс ленты сделан под фактическую сортировку — по дате убыванию, а не
«по приоритету», как было умолчанием в легаси.

### section_seo

```
section_seo
    id, site_id, section_id, target, field, value, inherit

    UNIQUE (site_id, section_id, target, field)
```

Строка на поле, а не колонка на поле: полей прибывает, а заполнено
у большинства разделов ноль. Значение ищется **вверх** по цепочке предков
до первого установленного — не копируется вниз. `target` разделяет страницу
раздела и карточку объекта, `inherit` управляет потомками, а не собой.
Разбор — [seo.md](seo.md).

### Формы и заявки

```
forms
    id, site_id, key, name, submit_label, success_text,
    notify_email, is_enabled, deleted_at

    UNIQUE (site_id, key)

form_fields
    id, site_id, form_id, key, label, kind, placeholder,
    options, is_required, sort_order

    UNIQUE (form_id, key)

form_submissions
    id, site_id, form_id (nullable), form_key, form_name,
    values (json), page_url, ip, user_agent, user_id, is_read

    KEY (site_id, is_read, id)
    KEY (site_id, form_key, id)
```

⚠️ **Заявка переживает форму.** Ключ, название и подписи полей скопированы,
`form_id` обнуляется вместо каскада: форму переделают, а обращение человека
обязано читаться через год. Разбор — [forms.md](forms.md).

### personal_access_tokens

Схема Sanctum плюс `site_id`, которого у него нет.

```
personal_access_tokens
    id, site_id, tokenable_type, tokenable_id, name, token,
    abilities, last_used_at, expires_at

    UNIQUE (token)
    KEY (tokenable_type, tokenable_id)
    KEY (site_id, expires_at)
```

⚠️ `tokenable_id` — это `users.id`, сквозной по установке. Без `site_id` токен,
выданный на сайте А, поднимал бы покупателя на домене сайта Б. Разбор —
[api.md](api.md).

### site_modules

```
site_modules
    id, site_id, module_key, is_enabled

    UNIQUE (site_id, module_key)
```

Строка появляется только у явно тронутых модулей: **отсутствие строки — это
«как заведено по умолчанию», а не «выключено»**. Иначе новый модуль в коде
требовал бы вставки строки каждому из сотен сайтов. Разбор —
[platform.md](platform.md#подключение-модулей-сайту).

### platform_admins

```
platform_admins
    id, email, name, password, is_active, last_login_at, remember_token

    UNIQUE (email)
```

Единственная **global**-таблица установки. `site_id` у неё быть не может:
супер-админ существует до сайтов и поверх всех сразу. Отдельная от `admins`
потому, что та персайтовая и целиком уезжает в выгрузку сайта — учётка
владельца платформы уехала бы клиенту вместе с его копией.

## Соглашения

**Все составные индексы ведут с `site_id`.** Любая выборка стартует с него,
иначе индекс не сработает.

**`ascii` для технических строк** — хосты, пути, слаги, ключи. Утроенный размер
`utf8mb4` в индексе не нужен там, где кириллицы не бывает по построению.

**JSON только для непоискового.** Настройки блока конкретного типа, снапшот
состава заказа, конфиг выгрузки. Всё, по чему нужен поиск или фильтр, — колонки:
в MySQL нет partial-индексов, поэтому индекс на `data->>'$.price'` в общей
таблице покрывал бы и новости, и контакты.

**Внешние ключи с `cascadeOnDelete`** там, где владение однозначно
(домены → сайт, блоки → зона, правила → блок).

**Мягкое удаление** у сущностей, которые администратор может удалить по ошибке:
`sites`, `sections`, `blocks`, объекты компонентов.

## Чего в схеме нет намеренно

**Таблицы инфоблоков.** Правило «раздел = один компонент» делает связь 1:1,
и отдельная таблица была бы join'ом на самом горячем пути.

**Фиксированных слотов.** Ни `price..price11`, ни `stock..stock10`, ни
`param1..param15`, как в легаси-`Message2001` (126 колонок). Цены, склады
и характеристики поедут в отдельные таблицы в M5.

**Колонок под файлы.** Файлы и галереи живут в `media` / `mediables` (M3).
В легаси колонка мультифайла существовала и всегда содержала пустую строку.

## Миграции

Платформенные — в `database/migrations/`, модульные — в
`modules/{tier}/{key}/database/migrations/` и подхватываются автоматически.

```bash
php artisan migrate
php artisan migrate:fresh --seed     # локально
```

Тесты идут по отдельной базе `korzillax_test` и **по MySQL, а не по sqlite**:
схема опирается на особенности MySQL 8, и тест на sqlite доказывал бы
работоспособность не той базы, на которой всё поедет в продакшене.

На больших таблицах в продакшене — `gh-ost`: одна `ALTER TABLE products` при
300–500 сайтах становится платформенным событием.
