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

MySQL 8.4, InnoDB, `utf8mb4_unicode_ci`. Все даты в UTC; отображение — в часовом поясе
конкретного заведения (ТЗ §7.3).

Миграции — `database/migrations/`, сидеры справочников — `database/seeders/`.

## Разделение «источник истины / локальные данные»

Это главное деление схемы (ТЗ §6.3). Синхронизация Restoplace перезаписывает первую группу и
**никогда** не трогает вторую.

| Управляется Restoplace | Управляется в «РестоМесто» |
|---|---|
| `venues` (кроме `is_manually_hidden`) | `venue_profiles` |
| `venue_schedules` | `venue_contacts` |
| `restaurant_brands` | `venue_cuisines`, `venue_venue_feature` |
| `cities.restoplace_city_id`, `name` | `venue_photos`, `venue_logos` |
| | `cities`: центр, радиус, склонение, SEO |

## Каталог

### `cities`
Города каталога. Создаются синхронизацией, дополняются администратором.

Ключевое: `slug` уникален; `latitude`/`longitude`/`detection_radius_km` нужны для автоопределения
ближайшего города по геолокации (ТЗ §8.2); `name_prepositional` («в Казани») — для SEO-заголовков;
`active_venues_count` — денормализация для списков.

### `restaurant_brands`
Организация Restoplace. Бренд и адрес не смешиваются: одна организация может иметь несколько
адресов, а карточка и доступность всегда относятся к адресу (ТЗ §7.2).

### `venues`
Заведение = адрес Restoplace. Зеркало внешних данных.

| Поле | Назначение |
|---|---|
| `restoplace_address_id` | **unique**, внешний ключ источника |
| `slug` | **unique(city_id, slug)** — уникален в пределах города, без ID в URL (ТЗ §18.2) |
| `timezone` | свой у каждого адреса |
| `source_email_encrypted` / `source_email_hash` | контактный email: шифруется, ищется по HMAC-hash |
| `is_restomesto_enabled`, `is_paid_tariff`, `is_online_booking_enabled` | три условия публикации (ТЗ §2.2) |
| `is_active` | итог расчёта — по нему строятся индексы каталога, карты и sitemap |
| `is_manually_hidden` | ручное скрытие администратором поверх данных Restoplace |
| `inactive_reason` | первая причина, по которой заведение скрыто, — для поддержки и админки |
| `source_deleted_at` | адрес удалён в Restoplace; жёсткого удаления нет (ТЗ §6.4) |

`is_active` — отдельная колонка, а не выражение в запросе: каталог, карта и sitemap фильтруют по
ней миллионы раз, а меняется она редко.

Индексы: `(is_active, city_id)`, `(city_id, is_active, name)`, `(latitude, longitude)`,
`source_updated_at`.

### `venue_profiles`, `venue_contacts`
Локальные дополнения карточки: описания, средний чек, главное фото; публичный телефон, сайт, VK,
Telegram, MAX. Телефон и сайт здесь — **переопределение**: если пусто, публикуется значение из
`venues`.

### `venue_schedules`
Интервал работы на день недели, локальное время. `closes_next_day` покрывает смены через полночь
(18:00–02:00).

### `cuisines`, `venue_features`, `venue_cuisines`, `venue_venue_feature`
Справочники, управляемые администратором, и связи с заведениями. Свободного ввода нет — иначе
рассыпаются фильтры и SEO-посадочные страницы.

### `venue_ratings`
Средняя оценка, количество и распределение по опубликованным отзывам (ТЗ §13.7). Отдельная
таблица, а не колонки в `venues`: синхронизация каталога не должна конкурировать за строку
заведения при каждом пересчёте рейтинга.

## Медиа

### `venue_photos`, `venue_logos`
Пути вариантов (`original`, `large`, `preview` / `webp`), размеры, вес, `status` обработки. Путь
строится по UUID — пользовательское имя файла в путь не попадает (ТЗ §16.4). До завершения
обработки `large_path`/`preview_path` пустые, и в интерфейсе показывается placeholder.

## Гости

### `guests`
`phone` (E.164) **unique** глобально: два аккаунта с одним подтверждённым номером — это дубль,
которого быть не должно (ТЗ §11.4). Email из OAuth ключом не является. Есть `anonymized_at` для
обезличивания вместо удаления там, где нужно сохранить историю броней и отзывов (ТЗ §22).

### `guest_social_accounts`
`unique(provider, provider_user_id)`. Флаги `provider_phone_verified` / `provider_email_verified`
решают, можно ли объединять аккаунты: неподтверждённого email недостаточно.

### `guest_login_challenges`
Попытка входа по телефону. Только `code_hash`, TTL, лимит попыток, одноразовость, IP и отпечаток
устройства (ТЗ §11.2, §21.1).

### `guest_favorite_venues`, `guest_favorite_tables`
Избранное. Любимые столики появятся в интерфейсе на втором этапе, но схема готова: при удалении
или выключении стола в Restoplace запись помечается неактивной, а не удаляется (ТЗ §12.3).

## Бронирование

### `guest_bookings`
Локальная запись о брони через «РестоМесто». Не независимый учёт: финальное решение принимает
Restoplace.

| Поле | Назначение |
|---|---|
| `local_booking_uuid` | **unique** — он же `Idempotency-Key` и `restomesto_booking_uuid` (ТЗ §9.3) |
| `restoplace_reserve_id` | **unique**, заполняется после успешного создания |
| `from` / `to` | UTC |
| `contact_*` | снимок контактов на момент брони |
| `status`, `payment_status` | enum-касты |
| `payment_url`, `payment_expires_at` | ссылка Restoplace; скрыта из сериализации, в логи не пишется |
| `source_status`, `sync_error` | состояние на стороне Restoplace для разбора расхождений |

## Кабинет ресторана

### `restaurant_users`
Пароля нет. `email_encrypted` + `email_hash` **unique**. Email не редактируется в «РестоМесто»:
источник — Restoplace (ТЗ §15.1).

### `restaurant_venue_accesses`
Право управлять адресом. Хранит `granted_via`, `granted_at`, `revoked_at`, `is_active` — доступ
производен от контактного email адреса и снимается синхронизацией при его смене, поэтому запись
не удаляется.

### `restaurant_login_codes`
Одноразовый код на 10 минут, только hash, лимит попыток.

## Отзывы

### `reviews`
`guest_booking_id` **unique** — одна бронь, один отзыв (ТЗ §13.1). `version` растёт при правке.

### `review_versions`
История версий: правка опубликованного отзыва создаёт новую версию и снова уходит на модерацию,
а предыдущая опубликованная остаётся видимой до решения (ТЗ §13.5).

### `review_moderations`
Решения ИИ и человека: категории нарушений, причина, confidence, сырой ответ провайдера. Нужны,
чтобы на вопрос «почему отзыв не опубликован» отвечала база, а не логи.

### `review_replies`, `review_complaints`
Ответ ресторана (можно скрыть администратором) и жалобы с причиной и статусом рассмотрения.

## SEO

### `seo_templates`
Шаблоны Title/Description по типу страницы с переменными `{venue_name}`, `{city_name}`,
`{address}`, `{cuisines}`, `{average_bill}`, `{rating}`.

### `seo_overrides`
Полиморфные индивидуальные метаданные (заведение, город, кухня, статическая страница). `variant`
уточняет составные страницы — например, город + кухня. Индивидуальное значение всегда перекрывает
шаблон.

### `redirects`
`from_path` **unique**. Основной источник записей — смена slug заведения: старый адрес обязан
отдавать 301 (ТЗ §18.2).

### `static_pages`
Статические страницы и юридические документы с версией.

## Администрирование и служебное

### `admin_users`
Email + пароль + 2FA. `sessions_invalidated_token` реализует «завершить все сессии»: смена
значения делает недействительными все ранее выданные сессии.

### `audit_logs`
`actor_type`, `actor_id`, `action`, `entity_type`, `entity_id`, `old_values`, `new_values`, `ip`,
`user_agent`, `request_id`. Значения проходят через `SensitiveDataMasker` — персональные данные и
секреты в аудит не попадают (ТЗ §20.3).

### `system_settings`
Настройки из /developer. Секреты хранятся зашифрованными и не отдаются в API.

### `consents`
Согласия: документ, версия, дата, IP, user agent. Отдельная запись на каждое согласие — обработка
ПДн, звонок для авторизации и рассылки принимаются независимо (ТЗ §22).

`subject` полиморфен: `guest`, `restaurant` или `booking`. Бронировать можно без аккаунта, и тогда
субъектом согласия становится сама бронь — иначе доказательства согласия есть только у вошедших.

### `restoplace_sync_logs`
Журнал синхронизаций. `window_to` последнего успешного запуска — точка отсчёта `updated_after` для
следующего, с нахлёстом на случай расхождения часов (ТЗ §6.2).

### `restoplace_webhook_events`
`event_id` **unique** — единственная защита от повторной обработки при повторной доставке.

### `external_api_logs`, `notification_logs`
Обращения к внешним API (усечённые и замаскированные тела, `request_id`, `idempotency_key`) и
отправленные уведомления (получатель — в маске).

## Инфраструктурные таблицы

`sessions`, `cache`, `cache_locks`, `jobs`, `job_batches`, `failed_jobs`, `password_reset_tokens`,
плюс таблицы `spatie/laravel-permission` (`roles`, `permissions`, `model_has_roles`,
`model_has_permissions`, `role_has_permissions`).

Таблицы `users` нет: аккаунты разделены на три guard-а. `sessions.user_id` заполняется Laravel
только для guard-а по умолчанию, поэтому колонки «тип владельца» там нет — она осталась бы пустой
и вводила в заблуждение.

## Проверка инвариантов

`tests/Feature/DatabaseSchemaTest.php` проверяет то, на что опирается доменная логика:
глобальную уникальность `restoplace_address_id`, уникальность slug в пределах города при
переиспользовании между городами, шифрование email с поиском по hash, глобальную уникальность
подтверждённого телефона, уникальность `Idempotency-Key`, хранение времени в UTC с показом в
поясе заведения, право на отзыв и скрытие платёжной ссылки из сериализации.
