diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py
index 47aebc1e..44a38206 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`