# Города и геотаргетинг

Два разных понятия, которые легко перепутать:

| Что | Таблица | Смысл |
|---|---|---|
| **Справочник РФ** | `geo_cities` (`global`) | 1111 городов страны: название, регион, координаты, население. Одинаков для всех сайтов |
| **Города сайта** | `cities` (`tenant`) | то, что сайт объявил своей географией: падежи, телефон, поддомен, город по умолчанию |

Справочник — **источник**, а не замена. Владелец набирает из него свой список:
всю Россию, если возит по стране, или два города, если продаёт без доставки.

## Почему города сайта не заменить справочником

На `cities` завязано четыре подсистемы, и все — по идентификатору города сайта:

- цены по городам ([catalog.md](catalog.md#цены)),
- стоимость доставки по городам ([shop.md](shop.md#доставка)),
- правила видимости блоков ([layout.md](layout.md)),
- падежи города в шаблонах заголовков ([seo.md](seo.md)).

Плюс у города сайта есть то, чего в справочнике нет и быть не может: свой
телефон отдела продаж, свой поддомен, свои падежи («доставка **в Ростове**»,
«купить **в Ростов**»), признак «этот показываем по умолчанию».

Поэтому справочник добавили рядом, а не вместо: цены, доставка, блоки и SEO
продолжают работать без единой правки.

## Справочник

```bash
php artisan geo:import                      # из database/data/cities-ru.json
php artisan geo:import --file=/path/to.json # из своего файла
```

- **Список едет файлом в репозитории**, а не тянется из интернета при
  установке: справочник городов меняется раз в пятилетку, а установка обязана
  подниматься на машине без внешней сети.
- **Категория `global`**: справочник одинаков для всех сайтов и **нужен
  автономной копии** — иначе в ней нечем будет добавить новый город
  ([export.md](export.md#что-уезжает-а-что-нет)).
- ⚠️ **Сверка при импорте — по нормализованному имени, а не по идентификатору.**
  Файл может приехать из другого источника, а повторный прогон не имеет права
  создать вторую Москву: половина цен оказалась бы привязана к ней.

Нормализация (`GeoCity::normalize`) заодно служит поиском в админке: `ё` → `е`,
дефисы и подчёркивания → пробелы. «ростов на дону» находит «Ростов-на-Дону»,
«Орёл» и «Орел» — один город.

Крупные города помечены `is_featured` и показываются первыми: список из 1111
строк, начинающийся с алфавита, а не с Москвы, бесполезен.

## Город запроса

`CityContext` — scoped, как и контекст сайта: в воркере очередей за один
процесс проходят разные арендаторы, и город одного не имеет права протечь
в задачу другого.

Посетитель выбирает город сам (`POST /city/{slug}`); выбор запоминается.
Если не выбрал — работает город сайта с флагом «по умолчанию».

## Геотаргетинг объектов

«Содержание доступно только в городах» — вкладка «Города» в форме объекта.
Ни одного отмеченного города — объект виден везде; это умолчание, а не «нигде»,
иначе включение геотаргетинга разом спрятало бы всё содержимое сайта.

```
object_city_rules (site_id, component_key, object_id, city_id)
```

- **Ключ объекта — пара «компонент + идентификатор»**, как в `object_seo`
  и `mediables`: своей таблицы объектов у ядра нет.
- **`city_id = 0` — «для посетителя, у которого город не определён»**. Нулём,
  а не NULL: в уникальном ключе MySQL два NULL не конфликтуют, и правило
  завелось бы дважды. Пункт нужен потому, что город есть не всегда — прямая
  ссылка, нет куки, общий домен, поисковый робот.
- **Скрытый объект не открывается и по своему адресу** (404): иначе он выпадал
  бы из списка, но жил бы по ссылке — и попал бы в поиск, откуда его в этот
  город и приводили.
- **Условие добавляется в `EloquentComponent::listingQuery()`**, а не в каждом
  модуле: иначе новый компонент молча показывал бы содержимое всех городов,
  и выяснялось бы это на сайте клиента.
- **Дешёвый выход первым.** Если у компонента на сайте нет ни одной строки
  правил — а это норма, — запрос не трогается вовсе. Список компонентов
  с правилами лежит в кэше под счётчиком городов.

⚠️ **Каталог в этом не участвует** (`supportsCityTargeting()` → `false`).
Условие ушло бы в горячий запрос витрины на сто тысяч строк ради случая,
которого не бывает: город у товара выражается ценой и наличием, а у них свои
таблицы и свои ключи.

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

## Чего ещё нет

| Что | Состояние |
|---|---|
| Экран «Города» в админке | следующий шаг: список городов сайта, поиск по справочнику, город по умолчанию, падежи и телефон |
| Кнопка «Все города РФ» | одним `INSERT … SELECT` из справочника в сайт |
| Определение города по IP | посетитель выбирает руками; авто-определение требует базы адресов и отдельного решения о приватности |
| Поддомены городов | колонка `cities.host` зарезервирована, резолвер её пока не читает |
