Merge pull request 'Москва в реестре городов лендинга и кабинета' (#3472) from feat/msk-public-city-registry into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m3s
Deploy Trade-In / test (push) Successful in 5m21s
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 1m52s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m50s

This commit is contained in:
lekss361 2026-09-12 11:04:17 +00:00
commit c3840019c5
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 pathlib
import re 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 = ( registry = (
pathlib.Path(__file__).resolve().parents[2] pathlib.Path(__file__).resolve().parents[2]
@ -573,7 +578,10 @@ def test_offered_cities_match_coverage_cities() -> None:
assert block, "не нашли OBLAST_CITIES — проверка стала бы пустой" assert block, "не нашли OBLAST_CITIES — проверка стала бы пустой"
offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))} 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, ( assert offered == answerable, (
"предлагаем выбрать и умеем отвечать — это должно быть одно множество.\n" "предлагаем выбрать и умеем отвечать — это должно быть одно множество.\n"

View file

@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest"; 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 { describeCityExpectation, describeCoverage } from "../coverage-copy";
import { COVERED_CITIES, REGION_NAME, SECONDARY_CITIES } from "../content";
import { CITY_COVERAGE } from "../landing-facts"; import { CITY_COVERAGE } from "../landing-facts";
import type { CoverageProbe } from "../public-api"; import type { CoverageProbe } from "../public-api";
@ -133,7 +134,7 @@ describe("describeCoverage", () => {
*/ */
describe("describeCityExpectation", () => { describe("describeCityExpectation", () => {
it("у каждого предлагаемого города есть замер — и лишних замеров нет", () => { 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(); const measured = CITY_COVERAGE.map((m) => m.city).sort();
expect(measured).toEqual(offered); expect(measured).toEqual(offered);
}); });
@ -202,3 +203,23 @@ describe("describeCityExpectation", () => {
expect(describeCityExpectation("Москва")).toBeNull(); 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) { if (resolvedCity === OTHER_CITY) {
setFeedback({ setFeedback({
kind: "info", kind: "info",
title: `Пока мы считаем только по одному региону${REGION_NAME}`, title: `Пока мы считаем только по двум регионам${REGION_NAME}`,
lines: [ lines: [
`Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`, `Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По Москве данные тоже собираем и оценку показываем — отдельно от области. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`,
], ],
}); });
return; return;

View file

@ -194,10 +194,11 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
<div className={styles.heroRegion}> <div className={styles.heroRegion}>
<p className={styles.heroRegionTitle}>{REGION_NAME}</p> <p className={styles.heroRegionTitle}>{REGION_NAME}</p>
<p className={styles.heroRegionText}> <p className={styles.heroRegionText}>
Полное покрытие {PRIMARY_CITY}. По остальным городам области ( Свердловская область: полностью {PRIMARY_CITY}, частично {" "}
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может {SECONDARY_CITIES.join(", ")}: там данных меньше, и оценка может
быть ориентировочной. По другим регионам не считаем вовсе не быть ориентировочной. Москва отдельно: данные собираем,
хотим гадать. оценку показываем, но насколько полно покрыт город, пока не
измеряли. По другим регионам не считаем вовсе не хотим гадать.
</p> </p>
</div> </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 { import {
LIVE_LISTING_SOURCES, LIVE_LISTING_SOURCES,
SOURCES, SOURCES,
@ -311,7 +311,7 @@ export const PAID_REPORT_RETENTION_MONTHS = 12;
// География // География
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const REGION_NAME = "Свердловская область"; export const REGION_NAME = "Свердловская область и Москва";
/** /**
* Города, которые сервис вообще умеет различать (это же список `city_hint` в * Города, которые сервис вообще умеет различать (это же список `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 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, (c) => c.id !== DEFAULT_CITY.id,
).map((c) => c.label); ).map((c) => c.label);
@ -490,9 +490,10 @@ export const FAQ: readonly FaqItem[] = [
}, },
{ {
id: "why-region", id: "why-region",
q: "Почему только Свердловская область?", q: "Почему только Свердловская область и Москва?",
a: [ a: [
`Мы собираем данные сами, город за городом, и по объёму эти города не равны: полнее всего покрыт ${PRIMARY_CITY}. По остальным городам области данных заметно меньше — там оценка скорее ориентировочная, и мы про это пишем, а не делаем вид, что разницы нет.`, `Мы собираем данные сами, город за городом, и по объёму эти города не равны: полнее всего покрыт ${PRIMARY_CITY}. По остальным городам области данных заметно меньше — там оценка скорее ориентировочная, и мы про это пишем, а не делаем вид, что разницы нет.`,
"По Москве данные тоже собираем и оценку показываем, но замеров полноты покрытия по городу пока нет — долю уверенных ответов там мы, в отличие от городов области, пока не публикуем.",
"Там, где сбора нет вовсе, оценка была бы догадкой с уверенным видом. Поэтому другие регионы мы не обещаем и добавляем их по мере появления реального покрытия, а не заранее.", "Там, где сбора нет вовсе, оценка была бы догадкой с уверенным видом. Поэтому другие регионы мы не обещаем и добавляем их по мере появления реального покрытия, а не заранее.",
], ],
}, },

View file

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

View file

@ -1,6 +1,6 @@
/** /**
* city-registry единый источник правды по городам Свердловской области, * city-registry единый источник правды по городам покрытия «Меры»:
* поддержанным сбором «Меры» (#2576 этап B: расширение на область). * Свердловская область (регион 66) и Москва (регион 77, PR-B).
* *
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного * Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
* города в тексте адреса житель Нижнего Тагила, вводя «Ленина, 1», получал * города в тексте адреса житель Нижнего Тагила, вводя «Ленина, 1», получал
@ -48,22 +48,43 @@ export interface OblastCity {
* произвольный текст города, не enum/slug). * произвольный текст города, не enum/slug).
*/ */
readonly label: string; readonly label: string;
/**
* Регион: 66 Свердловская область, 77 Москва. НЕ отправляется бэкенду
* нужен только фронту, чтобы отличить «частичное покрытие области» от
* отдельного региона. Большинство потребителей читают только id/label.
*/
readonly region: 66 | 77;
} }
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок // ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
// остальных — по охвату сбора, не алфавитный. // остальных — по охвату сбора, не алфавитный.
export const OBLAST_CITIES: readonly OblastCity[] = [ export const OBLAST_CITIES: readonly OblastCity[] = [
{ id: "ekaterinburg", label: "Екатеринбург" }, { id: "ekaterinburg", label: "Екатеринбург", region: 66 },
{ id: "berezovsky", label: "Берёзовский" }, { id: "berezovsky", label: "Берёзовский", region: 66 },
{ id: "verkhnyaya_pyshma", label: "Верхняя Пышма" }, { id: "verkhnyaya_pyshma", label: "Верхняя Пышма", region: 66 },
{ id: "sredneuralsk", label: "Среднеуральск" }, { id: "sredneuralsk", label: "Среднеуральск", region: 66 },
{ id: "nizhny_tagil", label: "Нижний Тагил" }, { id: "nizhny_tagil", label: "Нижний Тагил", region: 66 },
{ id: "kamensk_uralsky", label: "Каменск-Уральский" }, { id: "kamensk_uralsky", label: "Каменск-Уральский", region: 66 },
{ id: "pervouralsk", label: "Первоуральск" }, { id: "pervouralsk", label: "Первоуральск", region: 66 },
{ id: "revda", label: "Ревда" }, { id: "revda", label: "Ревда", region: 66 },
{ id: "serov", label: "Серов" }, { 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, для текста подсказки и * Дефолт формы Екатеринбург (визуальный/best-guess, для текста подсказки и
* подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по * подстановки значения ПОСЛЕ подтверждения). НЕ означает "отправляется по

View file

@ -259,7 +259,7 @@ export function useEstimateHistory(limit = 50) {
/** /**
* GET /api/v1/geocode/suggest?q=&limit=&city_hint= * GET /api/v1/geocode/suggest?q=&limit=&city_hint=
* Автокомплит адресов в Свердловской области для поля адреса (ParamsPanel). * Автокомплит адресов в регионах покрытия (66/77) для поля адреса (ParamsPanel).
* Debounce-friendly: вызывающий компонент дебаунсит строку query, хук * Debounce-friendly: вызывающий компонент дебаунсит строку query, хук
* кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов * кешируется по queryKey; `enabled` срабатывает только начиная с 3 символов
* (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select` * (бэкенд min 2, берём 3 чтобы не дёргать на 1-2 символа). `select`