Москва в реестре городов лендинга и кабинета #3472

Merged
lekss361 merged 1 commit from feat/msk-public-city-registry into main 2026-09-12 11:04:18 +00:00
8 changed files with 79 additions and 27 deletions

View file

@ -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"

View file

@ -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("Москва");
});
});

View file

@ -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;

View file

@ -194,10 +194,11 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
<div className={styles.heroRegion}>
<p className={styles.heroRegionTitle}>{REGION_NAME}</p>
<p className={styles.heroRegionText}>
Полное покрытие {PRIMARY_CITY}. По остальным городам области (
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
быть ориентировочной. По другим регионам не считаем вовсе не
хотим гадать.
Свердловская область: полностью {PRIMARY_CITY}, частично {" "}
{SECONDARY_CITIES.join(", ")}: там данных меньше, и оценка может
быть ориентировочной. Москва отдельно: данные собираем,
оценку показываем, но насколько полно покрыт город, пока не
измеряли. По другим регионам не считаем вовсе не хотим гадать.
</p>
</div>

View file

@ -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}. По остальным городам области данных заметно меньше — там оценка скорее ориентировочная, и мы про это пишем, а не делаем вид, что разницы нет.`,
"По Москве данные тоже собираем и оценку показываем, но замеров полноты покрытия по городу пока нет — долю уверенных ответов там мы, в отличие от городов области, пока не публикуем.",
"Там, где сбора нет вовсе, оценка была бы догадкой с уверенным видом. Поэтому другие регионы мы не обещаем и добавляем их по мере появления реального покрытия, а не заранее.",
],
},

View file

@ -149,7 +149,7 @@ export function describeCoverage(probe: CoverageProbe): CoverageVerdict {
tone: "none",
title: "Этот адрес вне области, по которой мы собираем данные",
lines: [
"Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг. По другим регионам данных у нас нет — и придумывать цифру мы не станем.",
"Сейчас это Свердловская область (Екатеринбург целиком и несколько городов вокруг) и Москва. По другим регионам данных у нас нет — и придумывать цифру мы не станем.",
"Проверьте, что в адресе указан нужный город, или попробуйте другой дом.",
],
tiles: [],

View file

@ -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, для текста подсказки и
* подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по

View file

@ -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`