# Ход работы

Состояние проекта по этапам ТЗ §28. Обновляется в конце каждого этапа вместе с кодом.

**Готово:** Этапы 1–7. **В работе:** Этап 8 — стабилизация (см. ниже, что сделано и что осталось).

| Этап | Состав | Статус |
|---|---|---|
| 1. Основа | Laravel, база, очереди, архитектура, дизайн-система, роли, документация, mock Restoplace | ✅ `56d2662` |
| 2. Каталог | синхронизация, города, страницы каталога, карточки, фильтры, карта, геолокация | ✅ `1744b23` |
| 3. Бронирование | слоты, столы, создание брони, idempotency, депозит, webhook, страница брони | ✅ `2a69329` |
| 4. Гостевой кабинет | flash call, VK, Яндекс, профиль, избранное, история броней | ✅ `391209d` |
| 5. Отзывы | право на отзыв, DeepSeek, модерация, версии, рейтинг, email ресторану, Telegram | ✅ `6a9a1a0` |
| 6. Кабинет ресторана | вход по email, несколько адресов, галерея, логотип, контакты, кухни, чек, ответы и жалобы | ✅ `47ae238` |
| 7. Админка и SEO | /developer на Filament, пользователи, заведения, отзывы, SEO, sitemap, robots, redirects, мониторинг | ✅ |
| 8. Стабилизация | нагрузочное тестирование, аудит безопасности, Core Web Vitals, E2E, CI/CD, backup | 🔄 частично |

Текущие цифры: 471 тест / 1618 утверждений, Pint — passed, PHPStan level 6 — no errors.

---

## Этап 1. Основа

**Схема БД** — 33 таблицы, см. [database-schema.md](database-schema.md). Ключевые инварианты
проверяются в `tests/Feature/DatabaseSchemaTest.php`.

**Интеграция Restoplace** — контракты в `app/Domain/RestoplaceIntegration/Contracts`, DTO, боевой
`HttpRestoplaceClient` и `FakeRestoplaceClient` на фикстурах. Драйвер переключается через
`RESTOPLACE_DRIVER`.

**Три guard-а** — `guest`, `restaurant`, `admin`; таблицы `users` нет. Роли админки через
spatie на guard `admin`.

**Приём webhook** — подпись HMAC, отсечение просроченных, дедупликация по `event_id`, журнал.

**Дизайн-система** — токены Tailwind 4 (оранжевый акцент), макет, нижняя навигация с safe area,
PWA-манифест, robots.txt с неотключаемыми запретами.

## Этап 2. Каталог

**Синхронизация** — `SyncCatalogAction` (полная и инкрементальная), `SyncVenueAction`,
`ResolveCityAction`, `RecalculateCityCenterAction`. Команда `php artisan restoplace:sync`,
планировщик: инкрементально каждые 5 минут, полный обход раз в сутки.

**Обработка webhook о заведениях** — `VenueEventHandler`: адрес перезапрашивается и применяется
тем же кодом, что и синхронизация, поэтому порядок доставки не важен.

**Доступность** — `AvailabilityService`, один пакетный запрос на экран, кеш 45 секунд.

**Страницы** — `/restaurants/{city}`, `/restaurants/{city}/{venue}`,
`/restaurants/{city}/cuisine/{cuisine}`, `/cities`. Фильтры и сортировки в URL, canonical и
noindex на параметрических страницах, 301 со старых slug, 410 для отключённых заведений.

**Карта** — Яндекс за `MapProviderInterface`, ленивая загрузка, кластеризация, фильтр по видимой
области. **Геолокация** — объяснение → запрос → подтверждение города; координаты не сохраняются.

## Этап 3. Бронирование

**Создание** — `CreateBookingAction`: точная повторная проверка, локальная запись до обращения к
API, `Idempotency-Key` = `local_booking_uuid`, обработка занятого стола с альтернативами.

**Депозит** — `payment_pending`, редирект на платёжную страницу Restoplace. Случай «депозит нужен,
ссылки нет» распознаётся отдельно.

**Форма** — `resources/views/components/booking-form.blade.php` + `resources/js/booking.js`.
Сетка слотов рендерится сервером, работает без JavaScript.

**Страница брони** — `/booking/{uuid}`, отмена через API, перезапрос статуса после оплаты.

**Сверка** — `ReserveEventHandler` для webhook + `ReconcileBookingsJob` каждые 10 минут.

## Этап 4. Гостевой кабинет

**Вход по звонку** — `StartPhoneLoginAction` заказывает вызов, `VerifyPhoneLoginAction` сверяет
код. Поставщик за `FlashCallProviderInterface`: `manual` для разработки, `voicepassword` —
боевой. Полный код нигде не хранится, новый звонок гасит прежние коды, пауза между звонками и
лимит попыток — на сервере.

**OAuth** — VK ID и Яндекс за `SocialAuthProviderInterface`, Authorization Code + PKCE, `state` в
сессии. Драйвер `fake` (`SOCIAL_AUTH_DRIVER=fake`) даёт пройти вход без регистрации приложений.
Кнопка провайдера показывается только когда он настроен.

**Объединение аккаунтов** — единственный ключ — подтверждённый телефон (ТЗ §11.4). Email не
объединяет. Занятый номер не переносится: гостю предлагается войти по нему.

**Кабинет** — `/profile` (имя, email, город, рассылки, подтверждение телефона, привязанные
соцсети), `/profile/bookings` (предстоящие, прошедшие, отменённые, платёжная ссылка пока
активна), `/favorites`. Кнопка избранного — обычная форма, работает без JavaScript.

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

**Согласия** — фиксируются с версией документа, IP и user agent. У гостя без аккаунта субъектом
согласия становится сама бронь.

## Этап 5. Отзывы

**Право на отзыв** — `ReviewEligibilityService`: завершившийся неотменённый визит по броне через
«РестоМесто», окно 90 дней, один отзыв на бронь. Одна и та же проверка при показе кнопки, при
открытии формы и при сохранении.

**ИИ-модерация** — `ReviewModerationProviderInterface` с драйверами `deepseek`, `fake` и `null`.
Отзыв сохраняется сразу и уходит в очередь `ai`: гость не ждёт ответа модели. Статус назначает
домен, а не провайдер; недоступность ИИ и неуверенное «одобряю» дают ручную модерацию, а не
публикацию.

**Версии** — правка создаёт новую версию и снова уходит на модерацию; опубликованный текст
остаётся на странице заведения, пока решение не принято. Отклонённую правку можно переписать,
вторую параллельную — нет.

**Рейтинг** — `RecalculateVenueRatingAction` пересчитывает средний балл и распределение целиком
по опубликованным отзывам после каждого решения модерации.

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

**Кабинет** — «Мои отзывы» со статусами и правкой, список броней, ждущих отзыва, кнопка отзыва в
истории броней.

**Уведомления** — `NotificationDispatcher`: письмо ресторану о новом опубликованном отзыве
(адрес не показывается и маскируется в журнале) и Telegram администратору об отзыве, ждущем
человека. Всё проходит через `notification_logs`.

## Этап 6. Кабинет ресторана

**Вход по email** — одноразовый код на 6 цифр на контактный email адреса, без пароля и без
регистрации. Ответ на запрос кода одинаков для любого адреса: иначе форма отвечает на вопрос,
какие заведения подключены. Аккаунт создаётся при первом успешном входе.

**Права** — `restaurant_venue_accesses` производны от `venues.source_email_hash`. Пересчёт при
входе и при синхронизации адреса, если email изменился; проверка — `VenuePolicy` через
`Gate::forUser()`. Чужой адрес отдаёт 404, а не 403.

**Карточка** (`/restaurant/venues/{id}`) — короткое и полное описание, средний чек, кухни (не
больше пяти), особенности, телефон, сайт, VK, Telegram, MAX. Поля Restoplace не редактируются.
Несколько адресов — список; один адрес открывается сразу.

**Медиа** — загрузка фото и логотипа, обработка в очереди `images`: поворот по EXIF, уменьшение,
WebP, удаление оригинала. Путь строится по UUID, имя файла пользователя в него не попадает.
Главное фото, порядок номерами (без JavaScript), удаление вместе с файлами.

**Отзывы заведения** — список опубликованных, ответ (один на отзыв, правится повторной отправкой,
скрытый администратором не переписывается) и жалоба с уведомлением администратора в Telegram.
Жалоба ничего не скрывает: отзыв остаётся на странице до решения администратора.

## Этап 7. Админка и SEO

**Панель `/developer`** — Filament 5 на guard `admin`, обязательная двухфакторная авторизация
через штатный механизм Filament поверх существующих зашифрованных колонок. Права — spatie;
единая проверка в `AdminResource` выводит их из префикса раздела, поэтому `AdminRole` и панель
не расходятся. Первый администратор заводится командой `php artisan admin:create`.

**Разделы** — модерация отзывов и разбор жалоб (перенесено с Этапов 5–6), заведения со сменой
адреса и автоматическим 301, справочники городов, кухонь и особенностей, гости с блокировкой и
обезличиванием, брони со сверкой, администраторы с завершением всех сессий, настройки, журнал
действий, упавшие задачи очередей и дашборд с тем, на что нужно реагировать.

**Журнал** — `AuditableObserver` пишет любую правку справочников и настроек, доменные действия
пишут сами под осмысленными именами. Значения проходят через `SensitiveDataMasker`.

**SEO** — `SeoResolver` собирает Title, Description, H1, canonical и og из шаблона типа страницы
и индивидуального переопределения; заполненное переопределение всегда сильнее. Шаблоны и
редиректы редактируются в админке, переопределение заведения — прямо на его карточке.

**Микроразметка** — `Restaurant` с кухнями, координатами, часами работы и `AggregateRating`
(только при наличии опубликованных отзывов) плюс `BreadcrumbList`.

**`sitemap.xml`** — главная, города, заведения, непустые посадочные страницы кухонь и статические
страницы. Кеш на час, ссылка из `robots.txt`.

## Этап 8. Стабилизация — что уже сделано

**Content-Security-Policy** (закрывает решение T5) — `script-src` без `'unsafe-inline'`, отдельный
middleware, режим наблюдения `CSP_REPORT_ONLY`. Проверена в браузере на живой Яндекс.Карте и форме
бронирования. В админке политика отсекла аватар с ui-avatars.com — вместо ослабления политики
аватар теперь рисуется на месте (`InitialsAvatarProvider`). Уступки (`unsafe-eval` для Alpine,
`unsafe-inline` для стилей) и их цена описаны в [assumptions.md](assumptions.md), T5.

**Резервное копирование** (ТЗ §26) — `php artisan backup:database`, ежедневно в 03:30, до полного
обхода каталога. Хранение последних 14 копий, пароль не уходит в список процессов. Команда
проверена вручную на рабочей базе.

**CI/CD** — `.gitverse/workflows/ci.yaml`: стиль, статический анализ, тесты против MySQL, сборка ассетов,
развёртывание вручную по кнопке.

**Нагрузочный сценарий** — `tests/Load/catalog.js` для k6 с порогами из ТЗ §19.2.

**Стенд развёрнут** — https://restomesto.com на 201.51.7.92, рядом с `teeu`, полностью на
заглушках. Схема сервера, cron и особенности — в [deployment.md](deployment.md).

Развёртывание вскрыло дефект, которого не видели ни тесты, ни браузер: предупреждение «на бою
включена заглушка» писалось на каждый запуск приложения — 469 строк за несколько минут работы
планировщика. `ThrottledLogger` повторяет такие сообщения не чаще раза в час.

**Статика админки** — панель раздаёт свой JavaScript из `public/js/filament`, а этот каталог не
создавал никто: `filament:assets` не был прописан в `post-autoload-dump`. Панель при этом
выглядела почти рабочей — разметка на месте, но боковое меню не раскрывалось, выпадающие списки
уезжали за край экрана, у кнопок пропадали подписи. HTTP-тесты такого не видят: сервер отдаёт
корректный ответ, а 404 приходят на скрипты. Теперь ссылки на статику сверяются с диском в
`AdminPanelAssetsTest`.

**Боковое меню админки** — под первым дефектом прятался второй, уже в самом Filament 5.7.5: список
свёрнутых групп хранится как `$persist(null)`, а проверяется через `collapsedGroups.includes(…)`.
Пока значения в localStorage нет — то есть в любом браузере, где панель открывают впервые, —
вызов падает, `x-show` возвращает undefined, и Alpine прячет пункты. Обработчик клика падает так
же, поэтому меню нельзя и раскрыть. Обход — `public/js/panel-sidebar.js`, приводит значение к
массиву до старта Alpine. Снимается, когда Filament починит это у себя.

**Журнал действий** — страница падала с 500, как только в журнале появлялась запись с изменёнными
полями: колонка была объявлена как `?array`, а Filament раскладывает массив в список значений и
вызывает форматирование для каждого элемента. Сразу после развёртывания журнал пуст, поэтому
дефект и дожил до первой записи. Обе страницы закрыты тестами (`AuditLogPageTest`).

**Выбор города и вход — модальными окнами** (по образцу проекта `teeu`). Обе кнопки шапки
остались ссылками на `/cities` и `/login`: без JavaScript оба сценария работают полностью, окна —
надстройка. Города ищутся на сервере (`/cities/search`, совпадение с начала любого слова):
их почти две сотни, встраивать справочник в каждую страницу незачем. Автоопределение по
геолокации переехало в то же окно и сохранило прежний порядок: объяснение → запрос координат →
подтверждение.

Форма входа общая у страницы и окна (`partials/auth-form`). Скрытое поле `back` возвращает гостя
на ту страницу, откуда он начал вход, и окно там открывается заново; после успешного входа он
остаётся на месте, а не уезжает в кабинет. Чужой адрес в `back` отбрасывается — это был бы
открытый редирект прямо из формы входа (`SameSiteUrl`).

### Что по Этапу 8 ещё не сделано

Это не оценка «примерно готово», а перечень невыполненного:

| Что | Состояние |
|---|---|
| Прогон нагрузочного теста | сценарий написан, но не запускался: нужен стенд, похожий на production, и боевой API Restoplace |
| E2E (Playwright / Dusk) | не начато; клиентские сценарии проверены вручную в браузере |
| Замер Core Web Vitals | не проводился |
| Аудит безопасности как процедура | точечные меры сделаны, сквозной прогон по чек-листу — нет |
| Любимые столики (ТЗ §12.3) | схема и модель готовы, интерфейса нет |
| Обезличивание по заявке самого гостя | администратор это умеет, кнопки в кабинете нет |
| Просмотр журналов интеграции в админке | таблицы пишутся, экрана нет |

---

## Что осталось за рамками пройденных этапов

Это не забытое, а сознательно отложенное — с указанием, где будет сделано.

| Что | Где сейчас | Когда |
|---|---|---|
Незакрытое по Этапу 8 перечислено выше, в его собственном разделе.

## Открытые вопросы

К заказчику и к Restoplace — в [assumptions.md](assumptions.md), раздел «Открытые вопросы».
Ни один из них не блокирует Этап 8.

## Как проверить состояние проекта

```bash
composer check                      # pint --test + phpstan + phpunit
php artisan restoplace:sync --full  # наполнить каталог из фикстур
php artisan serve                   # http://127.0.0.1:8000
```

Полезные URL после синхронизации: `/restaurants/kazan`, `/restaurants/kazan/drova-na-baumana`
(форма бронирования), `/restaurants/moskva/sahalin` (депозит от 6 гостей),
`/restaurants/kazan?view=map`, `/cities`.

Вход гостя при `FLASH_CALL_DRIVER=manual`: `/login`, любой номер, код `0000`. Чтобы проверить
вход через VK и Яндекс без регистрации приложений — `SOCIAL_AUTH_DRIVER=fake` в `.env`.

Отзывы при `AI_DRIVER=fake` публикуются автоматически; слова-маркеры `оскорбление`, `мат`,
`реклама`, `персональные данные` дают отклонение, `спорно` — ручную модерацию. Модерация идёт
через очередь, поэтому нужен воркер:

```bash
php artisan queue:work --queue=ai,images,notifications --stop-when-empty
```

Админка: `/developer`. Первый администратор — `php artisan admin:create`, роли —
`php artisan db:seed --class=AdminRoleSeeder`. Двухфакторная авторизация настраивается в профиле
при первом входе.

Кабинет заведения: `/restaurant/login`, email из фикстур — `drova@example.ru` (два адреса),
`pushkin@example.ru`, `sahalin@example.ru`. Код приходит письмом; при `MAIL_MAILER=log` он лежит
в `storage/logs/laravel.log`. Обработка фотографий идёт в очереди `images` — без воркера
фотография останется со статусом «в очереди на обработку».

## Новый дизайн (макет `design/new design and ui`)

Перенесён целиком: палитра, типографика (Golos Text + JetBrains Mono), радиусы,
три тени, логотип, шапка, подвал, главная, каталог, карточка заведения, кабинет
гостя и кабинет заведения.

**Шрифты самохостятся.** Макет предлагает Google Fonts, но внешние CDN
несовместимы со строгим CSP и офлайн-режимом PWA (ТЗ §10.2, §21). Взяты те же
семейства из `@fontsource`.

**Повторяющиеся элементы — классами дизайн-системы** (`btn`, `field`, `card`,
`slot`, `pill`, `badge`, `mono-label`) в `resources/css/app.css`: у кнопки шесть
состояний, у поля четыре, и держать их одинаковыми утилитами в двадцати
шаблонах не выходит. Раскладку по-прежнему задают утилиты Tailwind.

### Сознательные расхождения с макетом

| Что | Почему |
|---|---|
| Блок «Куда пойти» (подборки редакции) на главной не сделан | такой сущности в проекте нет; рисовать на выдуманных данных хуже, чем не рисовать |
| Кухня в фильтрах осталась селектом, чек — двумя полями | у полей есть контракт в URL и разбор запроса; менять его заодно с вёрсткой — напрашивающаяся ошибка |
| В кабинете заведения нет разделов «Меню» и «Статистика» | их нет в проекте; пункт меню, ведущий в никуда, хуже отсутствующего |
| Фотографии-плейсхолдеры из макета применяются только там, где фото нет | у заведений с фото показываем настоящие |
