Skip to content

DEV-402 — Геолокация и адресный флоу (frontend design)

Дата: 2026-06-01 YouTrack: DEV-402 (umbrella initiative) Зависит от: DEV-379 (импорт ГАР, расширен), DEV-380 (suggest API + GeoProvider, расширен) Premortem: dev-402-geo-address-flow-frontend.mdСтатус: Дизайн готов, ждёт расширения бэк-эпиков


1. Контекст

DEV-402 — umbrella-инициатива расширения геолокации Partizap на всю РФ. Текущая система: ручной справочник (СПб 18 районов + 69 станций метро; Москва 12 районов + 41 станция). Цель эпика: 85 регионов, ~1100 городов, дома из ГАР (~30M записей), автокомплит адреса, IP-определение города.

Под DEV-402 уже декомпозировано два backend-эпика:

  • DEV-379 — инфраструктура и импорт данных (ГАР XML-дамп → Postgres)
  • DEV-380 — API и runtime-интеграция (GeoProvider abstraction, suggest endpoint)

Этот документ описывает фронтовую часть эпика — она в исходный план 2026-04-21-geo-expansion-russia.md явно не входила («Frontend-изменения — отдельные таски»).

1.1 Принятые решения (по результатам brainstorming + premortem)

РешениеЗначениеИсточник
Backend стратегияПолный ГАР с домами (50ГБ), свой suggest (без DaData runtime)Brainstorm Q1: A
Frontend scope4 сценария + админка-просмотрBrainstorm Q2: a+b+c+d+e
Метро в форме продавцаГибрид — селект предзаполняется автоматически, продавец может перевыбратьBrainstorm Q3: гибрид
Определение города посетителяIP по умолчанию + опциональная точная геолокацияBrainstorm Q4: гибрид
Порядок поставкиСначала бэк (расширенный), затем фронтBrainstorm Q5: сначала бэк
Legacy-продукты без address_idПринудительный перевыбор (база пуста, юзеров нет) + триггер на bulk-миграцию при > 50 продавцовBrainstorm Q6: перевыбор; Premortem H-001
Шапка / фильтр каталогаИерархический picker (как Avito/WB) с поискомBrainstorm Q7: 7.4
Ключ адреса в публичном контрактеInternal address_id (UUID), не fias_idPremortem H-006
Координаты домовОбязательны в resolve-ответе (для гибрида метро)Premortem H-003
SLO suggestP95 ≤ 200ms, P99 ≤ 500ms при 100 RPSPremortem H-004
Качество suggestAcceptance gold-set 50 запросов + словарь синонимов на бэкеPremortem H-002
Защита suggestRate-limit + anon-session cookiePremortem H-005

1.2 Аудитория и успех

Аудитория: продавцы автозапчастей (вендоры размещают карточки) + покупатели на partizap.ru.

Критерий успеха: все 4 сценария работают + 0 регрессий в существующих фичах + полное прохождение manual regression checklist.

Горизонт оценки: 12 месяцев после релиза.


2. Архитектура

2.1 Высокоуровневая карта

┌─────────────────────────────────────────────────────────┐
│ ВИДЖЕТЫ / СЦЕНАРИИ                                       │
│  ├─ vendor-address-form     (форма продавца)            │
│  ├─ geo-city-picker         (иерархич. модал шапки)     │
│  └─ geo-banner              (IP-баннер главной)         │
└─────────────────────────────────────────────────────────┘
                      ↓ собраны из атомов
┌─────────────────────────────────────────────────────────┐
│ АТОМЫ (composables в features/geo-*)                    │
│  ├─ useGeoSuggest    — автокомплит по своему API        │
│  ├─ useGeoCascade    — регион→город (existing, расшир.) │
│  ├─ useGeoIpDetect   — SSR resolve по IP                 │
│  └─ useGeoLocate     — браузерная геолокация             │
└─────────────────────────────────────────────────────────┘
                      ↓ обращаются к
┌─────────────────────────────────────────────────────────┐
│ ENTITIES (схемы + эндпоинты)                             │
│  └─ entities/geo                                         │
│       ├─ model/geo.schema.ts                             │
│       │    + AddressSuggestion (address_id, source meta) │
│       │    + ResolvedAddress  (+ coords дома, metro)     │
│       │    + GeoIpResult                                 │
│       └─ api/endpoints.ts                                │
│            + suggest, resolve, ip, locate               │
└─────────────────────────────────────────────────────────┘
                      ↓ читают/пишут
┌─────────────────────────────────────────────────────────┐
│ STORES (pinia)                                           │
│  ├─ geo         — выбранный регион/город (cookies)       │
│  └─ geoLookup   — id↔name обратный резолв                │
└─────────────────────────────────────────────────────────┘

2.2 FSD-границы

  • features/geo-suggest, features/geo-cascade (existing), features/geo-ip-detect, features/geo-locate — атомы.
  • features/vendor-address-form, features/geo-city-picker, features/geo-banner — сценарии, импортируют атомы.
  • Cross-feature импорты запрещены (enforced ESLint). Атомы общаются только через entities/geo.

3. Контракт фронт ↔ бэк

3.1 Расширения существующих эндпоинтов

EndpointИзменения
GET /store/geo/regionsВозвращает 85 записей вместо 2. Без изменений схемы.
GET /store/geo/regions/:id/citiesПоддерживает ?q=<query> для серверного поиска.
POST/PUT /vendor/productsПринимает address_id (UUID, обязательно для новых продуктов; null допустим для legacy). Старые поля region_id, city_id, district_id, metro_station_id, address остаются для read-path и обратной совместимости — на сохранении бэк их перевыставляет через GeoResolver из address_id.

3.2 Новые эндпоинты (за бэком DEV-380)

EndpointНазначениеМетод
GET /store/geo/suggestАвтокомплит адреса?q=&city_id=&level=&limit=
GET /store/geo/resolveПолный адрес + координаты + ближайшее метро?address_id=
GET /store/geo/ipГород по IP(SSR-only, читает X-Forwarded-For)
POST /store/geo/locateГород по координатам браузера{ lat, lon }

Все 4 эндпоинта обязаны:

  • Требовать cookie PARTIZAP_SESSION (HTTP-only, гостевая выдаётся при первом GET страницы).
  • Соблюдать rate-limit: 60 req/min/session + 600 req/min/IP (см. §7.5).
  • Иметь записанный SLO в openapi.yaml (см. §7.4).

3.3 Схема ответов

ts
// AddressSuggestion — /store/geo/suggest
export const addressSuggestionSchema = z.object({
  address_id: z.string().uuid(),
  value: z.string(),              // "г Санкт-Петербург, Невский пр-кт, д 1"
  unrestricted: z.string(),       // полный с индексом
  level: z.enum(['region', 'city', 'district', 'street', 'house']),
  region_id: z.number().nullable(),
  city_id: z.number().nullable(),
  district_id: z.number().nullable(),
  street: z.string().nullable(),
  house: z.string().nullable(),
})

// ResolvedAddress — /store/geo/resolve
export const resolvedAddressSchema = addressSuggestionSchema.extend({
  coords: z.object({ lat: z.number(), lon: z.number() }).nullable(),
  metro_station_id: z.number().nullable(),       // ближайшая если < 1500m
  metro_distance_m: z.number().nullable(),
})

// GeoIpResult — /store/geo/ip
export const geoIpResultSchema = z.object({
  region_id: z.number().nullable(),
  city_id: z.number().nullable(),
  region_name: z.string().nullable(),
  city_name: z.string().nullable(),
  confidence: z.enum(['city', 'region', 'unknown']),
})

Важно (H-006): address_id — UUID, генерируемый бэком при resolve. fias_id остаётся на бэке как метаданные источника в таблице addresses (address_id, source, source_value). Фронт fias_id не использует.

3.4 Schema продукта

В product.schema.ts добавляется:

ts
// ProductDetail
address_id: z.string().uuid().nullable(),       // null для legacy

// ProductForm
address_id: z.string().uuid().optional(),

Существующие поля (region_id, city_id, district_id, metro_station_id, address) остаются для отображения карточек в каталоге и обратной совместимости.


4. Компоненты

4.1 Атомы (composables)

features/geo-suggest/composables/useGeoSuggest.ts (новое)

ts
function useGeoSuggest(opts: {
  cityId?: Ref<number | undefined>,
  level?: ('city' | 'street' | 'house')[],
  debounceMs?: number,
}) → {
  query: Ref<string>,
  suggestions: Readonly<Ref<AddressSuggestion[]>>,
  loading: Readonly<Ref<boolean>>,
  error: Readonly<Ref<Error | null>>,
  pick: (s: AddressSuggestion) => Promise<ResolvedAddress>,
  clear: () => void,
}

Внутри: debounce 250ms, abort предыдущего запроса (AbortController), LRU кэш 20 ключей query+cityId+level.

features/geo-cascade/composables/useGeoCascade.ts (расширить существующий)

  • Добавляется search: Ref<string> + клиентская фильтрация регионов по name (85 шт — приемлемо).
  • Для городов — серверный поиск через ?q= на /regions/:id/cities.
  • Existing initialize() остаётся как есть.

features/geo-ip-detect/composables/useGeoIpDetect.ts (новое)

ts
async function useGeoIpDetect() → {
  result: Ref<GeoIpResult | null>,
  loading: Ref<boolean>,
}

Через useFetch('/store/geo/ip') (SSR-friendly). Кэшируется в payload, повторно на клиенте не дёргается. Timeout 3s — лучше без баннера, чем медленная главная.

features/geo-locate/composables/useGeoLocate.ts (новое)

ts
function useGeoLocate() → {
  request: () => Promise<ResolvedAddress | null>,
  denied: Ref<boolean>,
}

Запрашивает navigator.geolocation.getCurrentPosition() → POST /store/geo/locate.

4.2 Сценарные компоненты

features/vendor-address-form/ui/VendorAddressForm.vue (новое)

  • Каскад регион→город через useGeoCascade (с поиском).
  • <AddressAutocomplete> (на базе UInputMenu или Combobox) → useGeoSuggest(cityId)pick() заполняет внутреннее состояние { address_id, address_text, district_id, metro_station_id }.
  • Метро-селект остаётся: предзаполняется из resolve (поле metro_station_id), продавец может перевыбрать.
  • Эмитит update:modelValue с объектом { region_id, city_id, district_id, metro_station_id, address_id, address } → встраивается в ProductFormPage вместо текущего GeoSelect + UInput address.
  • Legacy-режим: если product приходит с address_id == null → форма открывается, каскад восстанавливается из city_id, autocomplete пуст с badge Уточните адрес заново, submit-guard блокирует сохранение до новой выборки.

features/geo-city-picker/ui/GeoCityPickerModal.vue (новое)

  • Иерархическая модалка как у Avito/WB: регионы слева, города справа.
  • Поиск сверху — запрос /store/geo/suggest?q=&level=city,region (объединённый список с подсветкой совпадений).
  • Multi-city: чипы под input.
  • Кнопка «Определить автоматически» → useGeoLocate() → подсвечивает найденный город (без закрытия модалки).
  • Используется в widgets/header вместо текущего CitySelector.vue.

features/geo-banner/ui/GeoBanner.vue (новое)

Показывается на pages/index.vue если:

  • В cookie нет geo_region_id (новый посетитель)
  • И useGeoIpDetect() вернул confidence in ['city', 'region']
  • И в localStorage нет geo_banner_dismissed

Содержание: Ваш город — Москва? [Да, верно] [Сменить] + кнопка Уточнить точнее.

  • «Да»geoStore.setRegion + setCities → cookies → localStorage.setItem('geo_banner_dismissed', '1') → баннер исчезает.
  • «Сменить» → открывает GeoCityPickerModal.
  • «Уточнить точнее»useGeoLocate.request() → подсвечивает найденный город в баннере, кнопка Да обновляется.

4.3 Stores

stores/geo.ts — расширить:

  • selectedRegion и selectedCities хранят также address_id (если нужно для будущих фильтров).
  • Action applyIpDetection(GeoIpResult) — устанавливает регион/город из IP без записи в cookie до подтверждения пользователем.

stores/geoLookup.ts — без изменений (обратный резолв id↔name остаётся).

4.4 Admin (apps/admin)

features/admin-references расширить geo-таб:

  • Регионы — серверная пагинация + поиск (85 шт, но единый интерфейс).
  • Города — серверная пагинация + поиск + фильтр по региону (~1100).
  • Просмотр без CRUD (CRUD — отдельный таск; справочник заполняется ГАР-импортом).
  • entities/admin/api/endpoints.ts дополнить ?q= параметром.

5. Потоки данных

5.1 Создание продукта (новый продавец)

ProductFormPage (mounted, продукта нет)

   ├─ VendorAddressForm
   │     │
   │     ├─ useGeoCascade.fetchRegions()   ───► GET /store/geo/regions
   │     │
   │     ├─ Продавец выбирает регион → useGeoCascade.fetchCities()
   │     │     ───► GET /store/geo/regions/:id/cities
   │     │
   │     ├─ Продавец выбирает город (id=78) → useGeoSuggest(cityId=78) активен
   │     │
   │     ├─ Продавец вводит "невский 1"
   │     │     ↓ debounce 250ms, AbortController отменяет предыдущий
   │     │     ───► GET /store/geo/suggest?q=невский+1&city_id=78&limit=10
   │     │     ◄─── [{ address_id, value, district_id, … }]
   │     │
   │     ├─ Продавец выбирает строку → useGeoSuggest.pick()
   │     │     ───► GET /store/geo/resolve?address_id=…
   │     │     ◄─── ResolvedAddress { …, coords, metro_station_id, metro_distance_m }
   │     │
   │     ├─ Внутреннее состояние:
   │     │     { region_id=47, city_id=78, district_id=12,
   │     │       metro_station_id=33 (предзаполнен),
   │     │       address_id="a1b2…", address="Невский пр-кт, д 1" }
   │     │
   │     └─ Метро-селект показывает "Адмиралтейская" с бейджем "auto"

   └─ Submit → POST /vendor/products
        body: { …product, address_id, region_id, city_id, district_id,
                metro_station_id (если перевыбрал), address }

Бэк (GeoResolver) на сохранении сам перевалидирует address_id и проставит правильные region/city/district. Фронт отправляет полную выкладку — бэк её перезапишет.

5.2 Редактирование legacy-продукта (без address_id)

GET /vendor/products/:id → продукт с city_id=2, district_id=5, address="…",
                            address_id=null

VendorAddressForm.initialize(product):
   ├─ useGeoCascade.initialize({ region_id, city_id }) — восстанавливает каскад
   ├─ Метро-селект: предзаполняется product.metro_station_id (если был)
   └─ AddressAutocomplete: пустой ввод, visual state="empty-legacy",
       бейдж «Уточните адрес заново», input подсвечен warning-цветом

Submit-guard: canSubmit = computed(() => !!address_id)
Кнопка «Сохранить» disabled до выборки через suggest.

5.3 Фильтр каталога (шапка)

SSR (page load на partizap.ru/catalog)

   ├─ Header читает cookie geo_region_id/geo_city_ids
   │   └─ есть → geoStore.setRegion + setCities → CatalogPage передаёт
   │             geoQueryParams (region_id или city_ids)
   │   └─ нет  → "Вся Россия" в шапке; GeoBanner может показаться на /

   ├─ Клик "Москва +2 города" в шапке → открыть GeoCityPickerModal

GeoCityPickerModal
   ├─ Левая колонка: регионы (кэш сессии)
   ├─ Поиск сверху: debounce 200ms
   │     ↓ GET /store/geo/suggest?q=казан&level=city,region
   │     ◄── мердж регионов и городов с подсветкой совпадений

   ├─ Клик "Казань" → выбран регион (Татарстан) + город (Казань) → чип

   ├─ "Определить автоматически" → useGeoLocate
   │     ↓ navigator.geolocation.getCurrentPosition()
   │     ↓ POST /store/geo/locate { lat, lon }
   │     ◄── ResolvedAddress
   │     ↓ подсветить регион/город в модалке (без закрытия)

   └─ "Применить" → geoStore.setRegion(region) + setCities([city, …])
                     → cookies persist → emit close
                     → страница перерендерит каталог через geoQueryParams

5.4 IP-баннер на главной

SSR pages/index.vue
   ├─ const { data: ip } = await useFetch('/store/geo/ip', { timeout: 3000 })
   │   (бэк читает X-Forwarded-For, отдаёт {region_id, city_id, confidence, …})

   └─ const showBanner = computed(() =>
         !cookie.geo_region_id &&
         !localStorage.geo_banner_dismissed &&
         ip.confidence !== 'unknown'
      )

GeoBanner.vue (client mount, если showBanner)
   ├─ "Ваш город — Москва?"
   ├─ "Да, верно" → geoStore.setRegion+setCities → cookie+localStorage persist
   ├─ "Сменить" → открыть GeoCityPickerModal
   └─ "Уточнить точнее" → useGeoLocate.request → /store/geo/locate
                          → подсветить найденный city, кнопка "Да" обновляется

5.5 Отображение карточки продукта

Карточки в каталоге не меняются на уровне UI. geoLookup.resolve([products]) подгружает cityMap/districtMap (как сейчас, но теперь до ~1100 ключей по требованию). Renders: Москва, Тверская, м. Тверская.


6. Edge-кейсы и обработка ошибок

6.1 Suggest лежит (5xx / timeout)

СценарийПоведение
500 / timeoutuseGeoSuggest.error выставлен, dropdown показывает «Не удалось загрузить — попробуйте ещё раз» + retry. Поле редактируемое, но address_id не выставляется → submit-guard блокирует.
422 (q < 3)Console-warn, dropdown «Введите минимум 3 символа».
Сеть пропала между suggest и pickAbortController срабатывает; suggestions сбрасываются.

Не делаем fallback на free-text — выбран вариант A (свой suggest). Лучше пятиминутный простой формы, чем кривые адреса в БД.

6.2 Resolve вернул 404 (address_id не найден)

  • useGeoSuggest.pick() ловит 404 → toast «Адрес временно недоступен, выберите другой вариант».
  • suggestions сбрасываются.
  • Sentry-лог с address_id для расследования (рассинхрон индекса).

6.3 IP-определение не сработало

СлучайПоведение
confidence: 'unknown'Баннер не показывается. Шапка светит акцентом «Выбрать город».
Эндпоинт 5xx / timeout > 3sSSR useFetch возвращает error; баннер не рендерится.
confidence: 'region' (без города)Баннер: «Вы из Татарстана? Уточнить город?» — только «Сменить».

6.4 Геолокация браузера

СлучайПоведение
Permission denieduseGeoLocate.denied = true. Кнопка «Уточнить точнее» исчезает на сессию.
getCurrentPosition timeout 15sToast «Не удалось определить местоположение». Retry возможен.
Координаты вне РФ/store/geo/locate 422 → toast «Адрес вне зоны обслуживания».

6.5 Legacy-продукт со сломанным city_id

После ГАР-импорта city_id сохраняются (по плану — без breaking changes). Защита:

  • useGeoCascade.initialize ловит 404 на regions/:id/cities или отсутствие city_id в списке → каскад сбрасывается, баннер «Город из карточки больше не доступен, выберите заново».
  • Submit-guard блокирует.

6.6 Пустые состояния

ГдеЧто показываем
Suggest, q < 3 символов«Введите минимум 3 символа»
Suggest, q ≥ 3, 0 результатов«Адрес не найден — попробуйте уточнить запрос»
GeoCityPicker, поиск пустой«Ничего не найдено» + кнопка «Очистить поиск»
Каскад городов: 0 в регионеНе должно случиться, но защита: «Города не загружены» + retry

6.7 Race conditions

СлучайЗащита
Быстрый вводAbortController отменяет предыдущий запрос
Каскад: пользователь выбрал город до загрузки districtsSuggest disabled пока promise pending
GeoIpDetect ещё не вернулся, CityPicker уже открытБаннер ждёт promise; picker работает независимо

6.8 Кэширование

  • Регионы (85 шт) — кэш на сессию в geoStore.regions.
  • Города по региону — кэш в useGeoCascade.cities, инвалидируется при смене региона.
  • Suggest — LRU 20 ключей в useGeoSuggest.
  • geoLookup.cityMap/districtMap — кэш на сессию (может пухнуть до ~1100 ключей — приемлемо).
  • geo_ip — кэш в Nuxt payload, на клиенте не повторяется.

6.9 Accessibility

  • AddressAutocomplete — aria-autocomplete="list", навигация ↑↓, Enter, Esc.
  • GeoCityPickerModal — role="dialog", фокус-трап, Esc.
  • «Уточнить точнее» — aria-busy="true" пока promise pending, screen-reader announce «Определяем местоположение».

7. Backend Dependencies (БЛОКЕРЫ старта фронт-работ)

Фронт-эпик не стартует до письменного подтверждения от бэк-команды по всем пунктам ниже. Все требования — следствие premortem-решений (см. premortem-файл, дыры H-001…H-006).

7.1 ADR Address Identifier Architecture (H-006)

Owner:Дедлайн:Что: написать ADR partizap-docs/docs/dev/adr/ADR-XXX-address-identifier.md. Опции: internal address_id (UUID) с addresses (address_id, source, source_value) таблицей vs оставление fias_id в публичном контракте. Решение фронта: внутренний address_id. ADR фиксирует. Acceptance: ADR согласован с бэк-командой и архитектором; openapi.yaml обновлён под address_id.

7.2 Backend-таск на batch-геокодинг домов (H-003)

Owner:Дедлайн:Что: новый YouTrack-таск под DEV-402 (subtask), DEV-380 → blockedBy на него. Реализует batch-геокодинг адресов уровня дома, источник — OSM Nominatim self-host или DaData геокод-API (выбор по budget + квоте + покрытию малых городов). Таблица houses.coords. Acceptance: /store/geo/resolve для топ-10 случайных домов в МСК+СПб возвращает coords и предзаполненный metro_station_id если станция < 1500m. Manual проверка по карте.

7.3 GeoResolver: address_id ↔ region/city/district (H-006)

Owner:Дедлайн:Что: на сохранении продукта POST /vendor/products бэк извлекает из address_id правильные region_id, city_id, district_id через GeoResolver. Read-path продукта возвращает эти поля для рендера в каталоге. Acceptance: unit-тест бэка покрывает 5 случаев (московский дом, питерский, региональный город, посёлок, спорный fias_id).

7.4 SLO suggest (H-004)

Owner:Дедлайн:Что: записать в openapi.yaml SLO для /store/geo/suggest: P95 ≤ 200ms, P99 ≤ 500ms при 100 RPS на полном ГАР. Нагрузочный тест бэка через k6 (или wrk) с gold-set queries × 100 RPS × 5 мин на dev-стенде. Acceptance: evidence отчёт k6 с pass на dev-стенде до мержа фронт-эпика.

7.5 Rate-limit + anon-session (H-005)

Owner:Дедлайн:Что: бэк выдаёт гостям cookie PARTIZAP_SESSION (HTTP-only, при первом GET страницы). Rate-limit на /store/geo/suggest: 60 req/min/session + 600 req/min/IP. При превышении — 429 с Retry-After заголовком. Acceptance: load test 1000 RPS не валит сервис; нормальный юзер не получает 429 за разумный сценарий (форма + picker одновременно).

7.6 Acceptance suggest QA: gold-set (H-002)

Owner:Дедлайн:Что: бэк держит словарь синонимов и алиасов на стороне индекса: Питер/СПб/Спб → Санкт-Петербург; Мск → Москва; ул./пр-кт/мкр/б-р/р-н — раскрытие сокращений. Fuzzy ≥ 0.7 (pg_trgm) или typo-tolerance (Meilisearch). Gold-set 50 типичных запросов в partizap-docs/specs/geo-suggest-gold-set.md (создаётся в этом эпике). Acceptance: 50/50 запросов gold-set возвращают релевантный address_id в топ-3 на dev-стенде.

7.7 Bulk-миграция legacy-продуктов (H-001, условно)

Owner:Дедлайн:Что: если на момент готовности DEV-380 в проде > 50 продавцов с продуктами без address_id — создаётся отдельный backend-таск на CLI-миграцию через тот же suggest API (idempotent, dry-run, threshold confidence). Иначе шаг пропускается, форма продавца с принудительным перевыбором. Acceptance: проверка метрики «count(distinct vendor_id) where address_id is null» перед стартом фронт-работ.


8. Тестирование

8.1 Unit (Vitest) — атомы

ФайлПокрытие
useGeoSuggest.test.tsdebounce 250ms, AbortController, LRU кэш, 5xx → error, pick() с 404, empty query
useGeoCascade.test.ts (расширить)поиск по регионам клиентский, поиск по городам серверный, initialize() как есть
useGeoIpDetect.test.tsSSR useFetch, unknown confidence, 5xx → null без throw
useGeoLocate.test.tsgetCurrentPosition success → POST locate, denied, timeout

8.2 Unit — сценарные компоненты

ФайлПокрытие
VendorAddressForm.test.tsНовый продукт через autocomplete; legacy режим с blocked submit; метро-гибрид (auto + manual override); suggest 5xx → error UI
GeoCityPickerModal.test.tsЗагрузка регионов; выбор региона → города; поиск merged; multi-city чипы; «Определить автоматически» → useGeoLocate; «Применить» → geoStore + cookie
GeoBanner.test.tsCookie есть → не рендерится; cookie нет + unknown → не рендерится; cookie нет + city → рендерится; «Да» → geoStore + dismissed; «Сменить» → emit open-picker; «Уточнить точнее» → useGeoLocate

8.3 Schema-тесты

addressSuggestionSchema, resolvedAddressSchema, geoIpResultSchema — валидные / невалидные payloads (паттерн как у существующих geo.schema.test.ts).

8.4 Endpoint-тесты

endpoints.test.ts (расширить) — проверка URL-сборки для suggest, resolve, ip, locate. pnpm lint:endpoints enforces отсутствие хардкод-URL.

8.5 E2E (Playwright) — apps/main/tests/e2e/

ФайлСценарий
geo-banner.spec.tsX-Forwarded-For → баннер → «Да» → cookie → перезагрузка → баннер исчез
city-picker.spec.tsОткрыть picker → найти «Казань» поиском → выбрать → шапка обновилась
vendor-address-flow.spec.tsСоздать продукт → регион/город → «ленин» → выбрать → метро auto → сохранить → продукт с правильным адресом
vendor-legacy-address.spec.tsОткрыть legacy-продукт → autocomplete пуст с badge → submit заблокирован → выбрать новый адрес → submit ок

8.6 Manual regression checklist

Дополнить partizap-docs/docs/dev/frontend/manual-regression-checklist.md:

  • Главная: гео-баннер при первом заходе, после «Да» не возвращается
  • Шапка: смена города через picker, persist между сессиями
  • Каталог: фильтр multi-city на новом справочнике
  • Форма продавца: новый продукт через автокомплит
  • Форма продавца: legacy требует перевыбора
  • Карточка: имена города/района/метро отображаются

9. Вне scope

  • PostGIS / гео-радиус поиск (отложено backend ADR-004, Phase 3)
  • Карта (Leaflet / Yandex Maps) в карточке продукта — отдельный таск
  • Адрес до квартиры — только до дома (как в backend плане)
  • Admin CRUD геосправочника — отдельный таск; в этом эпике только просмотр + поиск
  • Перенос multi-city из cookies в auth-профиль — фича персонализации
  • A/B тест IP-баннера vs модалки — отложено
  • Перевод на Meilisearch — backend-решение, фронт-контракт остаётся
  • EN-локаль — только ru
  • Bulk-миграция legacy — условно (см. §7.7)

10. Открытые вопросы (от premortem)

IDВопросАдресат
H-006-1UUID генерируется на бэке при resolve или на этапе миграции схемы?Backend
H-006-2Нужна ли версионность addresses (history) для аудита смены источников?Backend / архитектор
H-003-1Достаточно ли покрытия OSM в малых городах РФ?Backend
H-003-2Стоимость и time-to-bootstrap self-host Nominatim для всей РФ?DevOps
H-004-1Работает ли pg_trgm на полном ГАР (~30M строк) или сразу Meilisearch?Backend
H-004-2Где хранится Meilisearch контейнер на инфраструктуре?DevOps
H-005-1Rate-limit state хранится в Redis или DB?Backend
H-005-2Анонимная сессия создаётся на SSR (Nuxt) или на первом API-запросе?Backend / Frontend
H-002-1Словарь синонимов хранится в БД или в коде бэка?Backend
H-002-2Кто отвечает за поддержку gold-set после релиза?Product / QA

11. Связанные документы