From c6711d05c40be77c3046a9a2f14dda97be4e1a14 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:40:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(mera-public):=20=D0=9C=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=B2=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B5=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реестр городов один на публичную форму и кабинет, и до сих пор он знал только Свердловскую область. Московский адрес нельзя было выбрать ни там, ни там, хотя бэкенд Москву поддерживает: реестр регионов знает 77, проба покрытия знает московские центроиды, оценка отрабатывает. Москва подана НЕ как ещё один «частично покрытый город области», а отдельной строкой: сбор по ней есть, а замера полноты покрытия нет, и приписывать ей формулировки области было бы неправдой. Для этого у `OblastCity` появилось поле `region`, а `SECONDARY_CITIES` теперь строится из `OBLAST_66_CITIES` — иначе Москва попала бы в перечисление городов области. Бэкенду поле не отправляется: это различение нужно только фронту. В `landing-facts.ts` строки Москвы сознательно нет — там лежат замеры покрытия по городам, а по Москве замера не делали. Придумывать цифру нельзя, поэтому паритет-тест копи сверяет замеры с `OBLAST_66_CITIES`. Тексты про географию переписаны в четырёх местах: плашка покрытия на главной, карточка бесплатной пробы, ответ FAQ про регионы и сообщение «адрес вне покрытия». Везде одна и та же честная формулировка: по области — полное и частичное покрытие, по Москве — считаем, но полноту не мерили. Юридический адрес в подвале не трогали, там «Свердловская область» — это адрес компании, а не география сервиса. Паритет-тест дропдауна и порогов покрытия на бэкенде дополнен Москвой: город, предлагаемый к выбору, обязан быть отвечаемым пробой. Фронт: 218 passed, tsc и eslint чистые. Бэкенд: 34 passed в затронутом файле. --- .../backend/tests/test_public_mera_api.py | 12 +++++- .../__tests__/coverage-copy.test.ts | 25 ++++++++++- .../_components/v3/FreeCheckCard.tsx | 4 +- .../app/mera-public/_components/v3/HeroV3.tsx | 9 ++-- .../frontend/src/app/mera-public/content.ts | 9 ++-- .../src/app/mera-public/coverage-copy.ts | 2 +- tradein-mvp/frontend/src/lib/city-registry.ts | 43 ++++++++++++++----- tradein-mvp/frontend/src/lib/trade-in-api.ts | 2 +- 8 files changed, 79 insertions(+), 27 deletions(-) diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 474450ac..815ba1ee 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -559,7 +559,12 @@ def test_offered_cities_match_coverage_cities() -> None: import pathlib import re - from app.api.v1.trade_in import COVERAGE_GREEN_CITIES, COVERAGE_YELLOW_CITIES, _fold_city + from app.api.v1.trade_in import ( + COVERAGE_GREEN_CITIES, + COVERAGE_MOSCOW_DISPLAY, + COVERAGE_YELLOW_CITIES, + _fold_city, + ) registry = ( pathlib.Path(__file__).resolve().parents[2] @@ -573,7 +578,10 @@ def test_offered_cities_match_coverage_cities() -> None: assert block, "не нашли OBLAST_CITIES — проверка стала бы пустой" offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))} - answerable = {_fold_city(c) for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES)} + answerable = { + _fold_city(c) + for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, COVERAGE_MOSCOW_DISPLAY) + } assert offered == answerable, ( "предлагаем выбрать и умеем отвечать — это должно быть одно множество.\n" diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index e46e5232..175694dc 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; -import { OBLAST_CITIES } from "@/lib/city-registry"; +import { OBLAST_66_CITIES } from "@/lib/city-registry"; import { describeCityExpectation, describeCoverage } from "../coverage-copy"; +import { COVERED_CITIES, REGION_NAME, SECONDARY_CITIES } from "../content"; import { CITY_COVERAGE } from "../landing-facts"; import type { CoverageProbe } from "../public-api"; @@ -133,7 +134,7 @@ describe("describeCoverage", () => { */ describe("describeCityExpectation", () => { it("у каждого предлагаемого города есть замер — и лишних замеров нет", () => { - const offered = OBLAST_CITIES.map((c) => c.label).sort(); + const offered = OBLAST_66_CITIES.map((c) => c.label).sort(); const measured = CITY_COVERAGE.map((m) => m.city).sort(); expect(measured).toEqual(offered); }); @@ -202,3 +203,23 @@ describe("describeCityExpectation", () => { expect(describeCityExpectation("Москва")).toBeNull(); }); }); + +/** + * Москва — отдельный регион (PR-B), не «частичное покрытие области». Три + * инварианта реестра, которые должны остаться верными при любом следующем + * расширении. + */ +describe("реестр городов — Москва как отдельный регион", () => { + it("Москва предлагается в дропдауне", () => { + expect(COVERED_CITIES).toContain("Москва"); + }); + + it("Москва не подаётся как «частичное покрытие области»", () => { + expect(SECONDARY_CITIES).not.toContain("Москва"); + }); + + it("REGION_NAME называет оба региона", () => { + expect(REGION_NAME).toContain("Свердловская область"); + expect(REGION_NAME).toContain("Москва"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx index ea877d67..a346bfbf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -149,9 +149,9 @@ export function FreeCheckCard() { if (resolvedCity === OTHER_CITY) { setFeedback({ kind: "info", - title: `Пока мы считаем только по одному региону — ${REGION_NAME}`, + title: `Пока мы считаем только по двум регионам — ${REGION_NAME}`, lines: [ - `Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`, + `Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По Москве данные тоже собираем и оценку показываем — отдельно от области. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`, ], }); return; diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx index a6a479c4..c295c1b6 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx @@ -194,10 +194,11 @@ export function HeroV3({ stats }: { stats: LandingStats }) {

{REGION_NAME}

- Полное покрытие — {PRIMARY_CITY}. По остальным городам области ( - {SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может - быть ориентировочной. По другим регионам не считаем вовсе — не - хотим гадать. + Свердловская область: полностью — {PRIMARY_CITY}, частично —{" "} + {SECONDARY_CITIES.join(", ")}: там данных меньше, и оценка может + быть ориентировочной. Москва — отдельно: данные собираем, + оценку показываем, но насколько полно покрыт город, пока не + измеряли. По другим регионам не считаем вовсе — не хотим гадать.

diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 44a6bb9f..92fc154b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -22,7 +22,7 @@ * нельзя. */ -import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry"; +import { DEFAULT_CITY, OBLAST_66_CITIES, OBLAST_CITIES } from "@/lib/city-registry"; import { LIVE_LISTING_SOURCES, SOURCES, @@ -311,7 +311,7 @@ export const PAID_REPORT_RETENTION_MONTHS = 12; // География // --------------------------------------------------------------------------- -export const REGION_NAME = "Свердловская область"; +export const REGION_NAME = "Свердловская область и Москва"; /** * Города, которые сервис вообще умеет различать (это же список `city_hint` в @@ -339,7 +339,7 @@ export const COVERED_CITIES: readonly string[] = OBLAST_CITIES.map( export const PRIMARY_CITY: string = DEFAULT_CITY.label; /** Остальные города области: сбор заведён, но данных кратно меньше. */ -export const SECONDARY_CITIES: readonly string[] = OBLAST_CITIES.filter( +export const SECONDARY_CITIES: readonly string[] = OBLAST_66_CITIES.filter( (c) => c.id !== DEFAULT_CITY.id, ).map((c) => c.label); @@ -490,9 +490,10 @@ export const FAQ: readonly FaqItem[] = [ }, { id: "why-region", - q: "Почему только Свердловская область?", + q: "Почему только Свердловская область и Москва?", a: [ `Мы собираем данные сами, город за городом, и по объёму эти города не равны: полнее всего покрыт ${PRIMARY_CITY}. По остальным городам области данных заметно меньше — там оценка скорее ориентировочная, и мы про это пишем, а не делаем вид, что разницы нет.`, + "По Москве данные тоже собираем и оценку показываем, но замеров полноты покрытия по городу пока нет — долю уверенных ответов там мы, в отличие от городов области, пока не публикуем.", "Там, где сбора нет вовсе, оценка была бы догадкой с уверенным видом. Поэтому другие регионы мы не обещаем и добавляем их по мере появления реального покрытия, а не заранее.", ], }, diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index 442c0edb..878df597 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -149,7 +149,7 @@ export function describeCoverage(probe: CoverageProbe): CoverageVerdict { tone: "none", title: "Этот адрес вне области, по которой мы собираем данные", lines: [ - "Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг. По другим регионам данных у нас нет — и придумывать цифру мы не станем.", + "Сейчас это Свердловская область (Екатеринбург целиком и несколько городов вокруг) и Москва. По другим регионам данных у нас нет — и придумывать цифру мы не станем.", "Проверьте, что в адресе указан нужный город, или попробуйте другой дом.", ], tiles: [], diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index ed968e4a..0ee29985 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -1,6 +1,6 @@ /** - * city-registry — единый источник правды по городам Свердловской области, - * поддержанным сбором «Меры» (#2576 этап B: расширение на область). + * city-registry — единый источник правды по городам покрытия «Меры»: + * Свердловская область (регион 66) и Москва (регион 77, PR-B). * * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного * города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал @@ -48,22 +48,43 @@ export interface OblastCity { * произвольный текст города, не enum/slug). */ readonly label: string; + /** + * Регион: 66 — Свердловская область, 77 — Москва. НЕ отправляется бэкенду — + * нужен только фронту, чтобы отличить «частичное покрытие области» от + * отдельного региона. Большинство потребителей читают только id/label. + */ + readonly region: 66 | 77; } // ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок // остальных — по охвату сбора, не алфавитный. export const OBLAST_CITIES: readonly OblastCity[] = [ - { id: "ekaterinburg", label: "Екатеринбург" }, - { id: "berezovsky", label: "Берёзовский" }, - { id: "verkhnyaya_pyshma", label: "Верхняя Пышма" }, - { id: "sredneuralsk", label: "Среднеуральск" }, - { id: "nizhny_tagil", label: "Нижний Тагил" }, - { id: "kamensk_uralsky", label: "Каменск-Уральский" }, - { id: "pervouralsk", label: "Первоуральск" }, - { id: "revda", label: "Ревда" }, - { id: "serov", label: "Серов" }, + { id: "ekaterinburg", label: "Екатеринбург", region: 66 }, + { id: "berezovsky", label: "Берёзовский", region: 66 }, + { id: "verkhnyaya_pyshma", label: "Верхняя Пышма", region: 66 }, + { id: "sredneuralsk", label: "Среднеуральск", region: 66 }, + { id: "nizhny_tagil", label: "Нижний Тагил", region: 66 }, + { id: "kamensk_uralsky", label: "Каменск-Уральский", region: 66 }, + { id: "pervouralsk", label: "Первоуральск", region: 66 }, + { id: "revda", label: "Ревда", region: 66 }, + { id: "serov", label: "Серов", region: 66 }, + { id: "moscow", label: "Москва", region: 77 }, ]; +/** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */ +export const REGION_LABELS: Record<66 | 77, string> = { + 66: "Свердловская область", + 77: "Москва", +}; + +/** Только область (регион 66) — прежнее множество, до добавления Москвы. */ +export const OBLAST_66_CITIES: readonly OblastCity[] = OBLAST_CITIES.filter( + (c) => c.region === 66, +); + +/** Москва одним объектом — тем же паттерном, что `DEFAULT_CITY = OBLAST_CITIES[0]` ниже. */ +export const MOSCOW_CITY: OblastCity = OBLAST_CITIES[OBLAST_CITIES.length - 1]; + /** * Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и * подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index 961ee2b8..7724eb77 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -259,7 +259,7 @@ export function useEstimateHistory(limit = 50) { /** * GET /api/v1/geocode/suggest?q=&limit=&city_hint= - * Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel). + * Автокомплит адресов в регионах покрытия (66/77) для поля адреса (ParamsPanel). * Debounce-friendly: вызывающий компонент дебаунсит строку query, хук * кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов * (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select`