Merge branch 'main' into fix/mera-public-contrast
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m20s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m20s
This commit is contained in:
commit
be59ca4452
5 changed files with 196 additions and 3 deletions
|
|
@ -810,6 +810,33 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
|
|||
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Части адреса приходят из реестров УЖЕ склеенными — и склеенными грязно:
|
||||
# ЕГРН `readable_address` печатает маркер без значения и оставляет лишний
|
||||
# пробел («г Екатеринбург, ул Фролова , д. 29, корп.»). Чиним склейку, а не
|
||||
# конкретный случай: один проход по частям — схлопнуть пробелы, выбросить
|
||||
# пустые и маркер-без-номера (корп./стр./лит./кв. и пр.), склеить обратно.
|
||||
_RE_ADDR_WS = re.compile(r"\s+")
|
||||
_RE_ADDR_BARE_MARKER = re.compile(
|
||||
r"^(?:д|дом|корп|корпус|к|стр|строение|соор|сооружение|лит|литера|литер"
|
||||
r"|кв|квартира|оф|офис|пом|помещение|вл|владение|уч|участок)\.?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def tidy_address(s: str) -> str:
|
||||
"""«г Екатеринбург, ул Фролова , д. 29, корп.» → «г Екатеринбург, ул Фролова, д. 29».
|
||||
|
||||
Часть без значения ВЫПАДАЕТ, а не печатается пустым маркером.
|
||||
"""
|
||||
parts = []
|
||||
for raw in s.split(","):
|
||||
part = _RE_ADDR_WS.sub(" ", raw).strip()
|
||||
if not part or _RE_ADDR_BARE_MARKER.match(part):
|
||||
continue
|
||||
parts.append(part)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeocodeSuggestion:
|
||||
label: str # формат "Малышева 30, Октябрьский район"
|
||||
|
|
@ -822,6 +849,12 @@ class GeocodeSuggestion:
|
|||
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
|
||||
fias_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Единственная точка, через которую проходят ВСЕ тиры (cadastral/
|
||||
# geoportal/houses/DaData/Nominatim) — чистим здесь, а не у каждого.
|
||||
object.__setattr__(self, "label", tidy_address(self.label))
|
||||
object.__setattr__(self, "full_address", tidy_address(self.full_address))
|
||||
|
||||
|
||||
# ── DaData suggest wrapper ──────────────────────────────────────────────────
|
||||
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
"""Склейка адреса в подсказках: пустые части ВЫПАДАЮТ, а не печатаются.
|
||||
|
||||
Прод-дефект: «г Екатеринбург, ул Фролова , д. 29, корп.» — двойной пробел
|
||||
перед запятой и висячее «корп.» без номера (ЕГРН `readable_address` отдаёт
|
||||
маркер даже когда значения нет, и все тиры /suggest пропускали строку насквозь).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import GeocodeSuggestion, tidy_address
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
# Прод-случай целиком.
|
||||
(
|
||||
"г Екатеринбург, ул Фролова , д. 29, корп.",
|
||||
"г Екатеринбург, ул Фролова, д. 29",
|
||||
),
|
||||
# Непустой корпус ОБЯЗАН остаться.
|
||||
(
|
||||
"г Екатеринбург, ул Фролова, д. 29, корп. 2",
|
||||
"г Екатеринбург, ул Фролова, д. 29, корп. 2",
|
||||
),
|
||||
# Соседние типы частей — та же болезнь, тот же механизм.
|
||||
("г Екатеринбург, ул Мира, д. 1, стр.", "г Екатеринбург, ул Мира, д. 1"),
|
||||
("г Екатеринбург, ул Мира, д. 1, лит.", "г Екатеринбург, ул Мира, д. 1"),
|
||||
("г Екатеринбург, ул Мира, д. 1, кв.", "г Екатеринбург, ул Мира, д. 1"),
|
||||
("г Екатеринбург, ул Мира, д. 1, стр. 5", "г Екатеринбург, ул Мира, д. 1, стр. 5"),
|
||||
("г Екатеринбург, ул Мира, д. 1, лит. А", "г Екатеринбург, ул Мира, д. 1, лит. А"),
|
||||
# Пустая часть посередине и подряд идущие запятые.
|
||||
("г Екатеринбург, , ул Мира, корп. , д. 1", "г Екатеринбург, ул Мира, д. 1"),
|
||||
# Маркер без точки и без номера.
|
||||
("г Екатеринбург, ул Мира, д 1, корпус", "г Екатеринбург, ул Мира, д 1"),
|
||||
# Чистая строка не меняется.
|
||||
("г Екатеринбург, ул Малышева, д 30", "г Екатеринбург, ул Малышева, д 30"),
|
||||
],
|
||||
)
|
||||
def test_tidy_address(raw: str, expected: str) -> None:
|
||||
assert tidy_address(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"г Екатеринбург, ул Фролова , д. 29, корп.",
|
||||
"г Екатеринбург, , ул Мира, корп. , д. 1",
|
||||
"г Екатеринбург, ул Мира, д. 1, стр. , лит.",
|
||||
],
|
||||
)
|
||||
def test_no_double_spaces_or_dangling_parts(raw: str) -> None:
|
||||
out = tidy_address(raw)
|
||||
assert " " not in out
|
||||
assert " ," not in out
|
||||
assert not out.endswith(",")
|
||||
assert out.split(", ")[-1] not in {"корп.", "стр.", "лит.", "кв.", "д."}
|
||||
|
||||
|
||||
def test_suggestion_cleans_both_label_and_full_address() -> None:
|
||||
"""Чистка стоит в единой точке — её проходят ВСЕ тиры /suggest."""
|
||||
s = GeocodeSuggestion(
|
||||
label="ул Фролова , д. 29, корп.",
|
||||
full_address="г Екатеринбург, ул Фролова , д. 29, корп.",
|
||||
lat=56.8,
|
||||
lon=60.6,
|
||||
kind="house",
|
||||
)
|
||||
assert s.label == "ул Фролова, д. 29"
|
||||
assert s.full_address == "г Екатеринбург, ул Фролова, д. 29"
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Инфраструктурные имена не должны попадать в файлы пользовательской копии.
|
||||
*
|
||||
* 30.08.2026 «Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026)»
|
||||
* доехала до экрана: `CITY_COVERAGE_SOURCE` собирается в `coverage-copy.ts` и
|
||||
* рендерится в `EstimateFlow.tsx:323`. На первом экране лендинга её не видно —
|
||||
* она появляется в форме проверки, после выбора города. Поэтому grep по
|
||||
* отрендеренной главной давал ноль, и дефект выглядел несуществующим.
|
||||
*
|
||||
* Отсюда форма гейта: он смотрит в ИСХОДНИК, а не в вывод. Какой именно путь
|
||||
* рендера сегодня показывает строку, знать не нужно — завтра он будет другим,
|
||||
* а хостнейм прода посетителю не сообщает ничего ни на одном из них.
|
||||
*
|
||||
* Скан идёт по всему тексту файла, включая комментарии: комментарий рядом с
|
||||
* копией — заготовка следующей строки копии, и «на боевой базе» в нём ничем
|
||||
* не хуже имени машины.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "..");
|
||||
|
||||
/**
|
||||
* Имена машин, контейнеров и внутренних хостов. Список ЯВНЫЙ, а не эвристика:
|
||||
* эвристика на «что-то похожее на хостнейм» ловила бы доменные имена в текстах
|
||||
* про источники данных (domclick.ru, avito.ru), которые тут законны.
|
||||
*
|
||||
* `gendsgn.ru` в список НЕ входит намеренно: это публичный домен B2B, на
|
||||
* который лендинг ссылается кнопкой «Для бизнеса» (`B2B_URL` в content.ts).
|
||||
* Запрет на него сломал бы рабочую ссылку — граница проходит по «внутреннее
|
||||
* имя машины» против «адрес, который посетитель и так увидит в браузере».
|
||||
*/
|
||||
const BANNED = [
|
||||
"poincare",
|
||||
"beget",
|
||||
"tradein-postgres",
|
||||
"tradein-backend",
|
||||
"tradein-scraper",
|
||||
"gendesign-postgres",
|
||||
"gendesign-backend",
|
||||
"glitchtip",
|
||||
"forgejo",
|
||||
];
|
||||
|
||||
/** Файлы копии: то, из чего собирается видимый посетителю текст. */
|
||||
function copySources(dir: string, acc: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === "__tests__" || name === "node_modules") continue;
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
copySources(full, acc);
|
||||
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
describe("публичная копия не содержит инфраструктурных имён", () => {
|
||||
const files = copySources(ROOT);
|
||||
|
||||
it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => {
|
||||
// Без этой проверки сломанный обход каталога дал бы пустой список и
|
||||
// «нарушений нет» на любом содержимом.
|
||||
expect(files.length).toBeGreaterThan(20);
|
||||
expect(files.some((f) => f.endsWith("landing-facts.ts"))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith("coverage-copy.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(BANNED)("нигде не встречается «%s»", (needle) => {
|
||||
const hits: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
text.split("\n").forEach((line, i) => {
|
||||
if (line.toLowerCase().includes(needle)) {
|
||||
hits.push(`${file.slice(ROOT.length + 1)}:${i + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(
|
||||
hits,
|
||||
`«${needle}» — инфраструктурное имя в файле пользовательской копии. ` +
|
||||
`Посетителю оно не сообщает ничего: «на боевой базе» несёт весь смысл. ` +
|
||||
`Строка может не рендериться сегодня и начать рендериться завтра — ` +
|
||||
`так и вышло с CITY_COVERAGE_SOURCE в EstimateFlow.tsx.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* ЧИСЛА ИЗ МАКЕТА СНЯТЫ (29.08.2026). Макет утверждал «по нашим данным,
|
||||
* средний разрыв между первой ценой в объявлении и ценой сделки — 4,1%, а у
|
||||
* провисевших дольше трёх месяцев доходит до 9%», «расхождение с кадастровой
|
||||
* до 40%» и плитки −4,3% / ×2,4 / 83%. Прод-замер (poincare, 29.08.2026)
|
||||
* до 40%» и плитки −4,3% / ×2,4 / 83%. Прод-замер (29.08.2026)
|
||||
* не подтвердил ни одно:
|
||||
*
|
||||
* | макет | что показал замер |
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export interface MeasuredValue {
|
|||
|
||||
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
|
||||
const BACKTEST_SOURCE =
|
||||
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
|
||||
"Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " +
|
||||
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
||||
|
||||
/**
|
||||
|
|
@ -204,7 +204,7 @@ export interface CityCoverageMeasure {
|
|||
}
|
||||
|
||||
export const CITY_COVERAGE_SOURCE =
|
||||
"Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " +
|
||||
"Симуляция пробы покрытия на боевой базе (30.08.2026): случайные адреса " +
|
||||
"активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " +
|
||||
"число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " +
|
||||
"собственный адрес из когорты исключён";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue