Merge branch 'main' into fix/mera-kpi-grid-and-table
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
This commit is contained in:
commit
3f57171f63
16 changed files with 710 additions and 40 deletions
|
|
@ -82,6 +82,22 @@ jobs:
|
||||||
python3 scripts/check-caddy-snippet-mounts.py --selftest
|
python3 scripts/check-caddy-snippet-mounts.py --selftest
|
||||||
python3 scripts/check-caddy-snippet-mounts.py
|
python3 scripts/check-caddy-snippet-mounts.py
|
||||||
|
|
||||||
|
- name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя"
|
||||||
|
# ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы
|
||||||
|
# `backend`/`postgres`/`frontend` и оба сидят в общей сети
|
||||||
|
# gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент
|
||||||
|
# берёт любой.
|
||||||
|
#
|
||||||
|
# 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд
|
||||||
|
# ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ
|
||||||
|
# ленты сделок, без строк сверки и без подписи разброса — то есть без
|
||||||
|
# единого доказательства. Отказ тихий: fetch не бросает, приходит
|
||||||
|
# валидный чужой ответ; а из-за двоения часть перегенераций попадала
|
||||||
|
# в правильный адрес, и поломка выглядела случайной.
|
||||||
|
run: |
|
||||||
|
python3 scripts/check-compose-ambiguous-hosts.py --selftest
|
||||||
|
python3 scripts/check-compose-ambiguous-hosts.py
|
||||||
|
|
||||||
- name: "Guard: Caddyfile синтаксически валиден"
|
- name: "Guard: Caddyfile синтаксически валиден"
|
||||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ meraocenka.ru {
|
||||||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||||||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||||||
# «/», и запрос закольцуется.
|
# «/», и запрос закольцуется.
|
||||||
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs
|
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
|
||||||
handle @meraPages {
|
handle @meraPages {
|
||||||
rewrite * /trade-in/mera-public{path}
|
rewrite * /trade-in/mera-public{path}
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
|
@ -361,7 +361,7 @@ meraocenka.ru {
|
||||||
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
||||||
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
|
||||||
# со слэшем и здесь нормализуются.
|
# со слэшем и здесь нормализуются.
|
||||||
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs)/$
|
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
|
||||||
handle @meraShortSlash {
|
handle @meraShortSlash {
|
||||||
redir * /{re.shortslash.1} permanent
|
redir * /{re.shortslash.1} permanent
|
||||||
}
|
}
|
||||||
|
|
@ -446,7 +446,7 @@ meraocenka.ru {
|
||||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
||||||
# (бесконечный цикл; поймано на локальном стенде).
|
# (бесконечный цикл; поймано на локальном стенде).
|
||||||
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/
|
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/
|
||||||
handle @meraLongPages {
|
handle @meraLongPages {
|
||||||
route {
|
route {
|
||||||
uri strip_prefix /trade-in/mera-public
|
uri strip_prefix /trade-in/mera-public
|
||||||
|
|
|
||||||
162
scripts/check-compose-ambiguous-hosts.py
Executable file
162
scripts/check-compose-ambiguous-hosts.py
Executable file
|
|
@ -0,0 +1,162 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282).
|
||||||
|
|
||||||
|
ПОЧЕМУ. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свои сервисы
|
||||||
|
`backend`, `postgres`, `frontend`, и оба подключены к общей сети
|
||||||
|
`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой:
|
||||||
|
|
||||||
|
изнутри tradein-frontend:
|
||||||
|
backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
|
||||||
|
tradein-backend → 172.18.0.6
|
||||||
|
|
||||||
|
30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000`
|
||||||
|
уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated
|
||||||
|
user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки
|
||||||
|
«прогноз против факта» и без подписи разброса. Страница про точность — без
|
||||||
|
единого доказательства.
|
||||||
|
|
||||||
|
Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP-
|
||||||
|
ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций
|
||||||
|
попадала в правильный адрес — утром страница отдавалась с данными, к обеду без
|
||||||
|
них, и выглядело это как случайная поломка, а не как ошибка конфигурации.
|
||||||
|
|
||||||
|
У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу
|
||||||
|
ПТИЦЫ. Там спасало лишь несовпадение кредов — то есть отказ вместо тихого
|
||||||
|
чтения не тех данных. На такую защиту полагаться нельзя: она держится на том,
|
||||||
|
что у чужой базы нет пользователя с нашим паролем.
|
||||||
|
|
||||||
|
ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть
|
||||||
|
двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост
|
||||||
|
— ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно:
|
||||||
|
подставить `container_name` (`tradein-backend`, `tradein-postgres`), он
|
||||||
|
однозначен по построению.
|
||||||
|
|
||||||
|
ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в
|
||||||
|
контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба
|
||||||
|
compose-файла.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml"
|
||||||
|
PTICA_COMPOSE = REPO / "docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие.
|
||||||
|
_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M)
|
||||||
|
# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN).
|
||||||
|
_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+")
|
||||||
|
|
||||||
|
|
||||||
|
def service_names(text: str) -> set[str]:
|
||||||
|
"""Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они
|
||||||
|
объявляются в своих секциях с тем же отступом, поэтому список заведомо шире
|
||||||
|
нужного — и это безопасная сторона, лишнее имя даст лишнюю проверку, а не
|
||||||
|
пропуск."""
|
||||||
|
return set(_SERVICE_RE.findall(text))
|
||||||
|
|
||||||
|
|
||||||
|
def violations(mera: str, ptica: str) -> list[str]:
|
||||||
|
ambiguous = service_names(mera) & service_names(ptica)
|
||||||
|
if not ambiguous:
|
||||||
|
return []
|
||||||
|
found: list[str] = []
|
||||||
|
for num, line in enumerate(mera.splitlines(), 1):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
for host in _HOST_RE.findall(line):
|
||||||
|
if host in ambiguous:
|
||||||
|
found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> int:
|
||||||
|
"""Гейт обязан уметь краснеть — иначе зелёный ничего не значит."""
|
||||||
|
ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n"
|
||||||
|
|
||||||
|
# У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение
|
||||||
|
# пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить».
|
||||||
|
# На этом селфтест и провалился в первой редакции.
|
||||||
|
bad = (
|
||||||
|
"services:\n backend:\n image: x\n"
|
||||||
|
" frontend:\n environment:\n URL: \"http://backend:8000\"\n"
|
||||||
|
)
|
||||||
|
if not violations(bad, ptica):
|
||||||
|
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
bad_dsn = (
|
||||||
|
"services:\n postgres:\n image: y\n"
|
||||||
|
" backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n"
|
||||||
|
)
|
||||||
|
if not violations(bad_dsn, ptica):
|
||||||
|
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ok = (
|
||||||
|
"services:\n backend:\n image: x\n"
|
||||||
|
" frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n"
|
||||||
|
)
|
||||||
|
if violations(ok, ptica):
|
||||||
|
print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Закомментированная строка — не конфигурация.
|
||||||
|
commented = (
|
||||||
|
"services:\n backend:\n image: x\n"
|
||||||
|
" frontend:\n environment:\n # URL: \"http://backend:8000\"\n"
|
||||||
|
)
|
||||||
|
if violations(commented, ptica):
|
||||||
|
print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.selftest:
|
||||||
|
return selftest()
|
||||||
|
|
||||||
|
for path in (MERA_COMPOSE, PTICA_COMPOSE):
|
||||||
|
if not path.exists():
|
||||||
|
print(f"НЕ НАЙДЕН: {path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
mera = MERA_COMPOSE.read_text(encoding="utf-8")
|
||||||
|
ptica = PTICA_COMPOSE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
shared = service_names(mera) & service_names(ptica)
|
||||||
|
if not shared:
|
||||||
|
print("пересекающихся имён сервисов нет — проверять нечего")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
found = violations(mera, ptica)
|
||||||
|
if not found:
|
||||||
|
print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr)
|
||||||
|
for line in found:
|
||||||
|
print(f" {line}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n"
|
||||||
|
"в два адреса и клиент берёт любой. Подставьте container_name\n"
|
||||||
|
"(tradein-backend, tradein-postgres) — он однозначен.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -90,6 +90,11 @@ check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
|
||||||
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
|
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
|
||||||
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
|
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
|
||||||
|
|
||||||
|
# 1d2. Страница «МЕРА для бизнеса» — короткий адрес, на который ведут пункт
|
||||||
|
# шапки и подвала. До 31.08.2026 они вели в закрытый контур и приводили
|
||||||
|
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
|
||||||
|
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
|
||||||
|
|
||||||
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
|
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
|
||||||
# канонический адрес, а старые ссылки и закладки продолжают работать.
|
# канонический адрес, а старые ссылки и закладки продолжают работать.
|
||||||
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
|
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@
|
||||||
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
|
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
|
||||||
квартала, нет площади) — «число некрасивое» причиной не является.
|
квартала, нет площади) — «число некрасивое» причиной не является.
|
||||||
|
|
||||||
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность),
|
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
|
||||||
у строки заполнено. Свежесть — порядок квартала сделки.
|
схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
|
||||||
|
|
||||||
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
|
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
|
||||||
----------------------------------------------------------
|
----------------------------------------------------------
|
||||||
|
|
@ -69,7 +69,10 @@
|
||||||
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
|
нет ни координат окна, ни констант проекции: точку дома по ней нельзя
|
||||||
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
|
поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий —
|
||||||
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
|
92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт
|
||||||
показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`).
|
показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`):
|
||||||
|
строка, которой нечем нарисовать карту, полнее строки с картой быть не
|
||||||
|
может. Это признак «поле заполнено», как район и этаж, а не величина
|
||||||
|
ошибки, — правило отбора выше не нарушено.
|
||||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
||||||
|
|
@ -97,7 +100,7 @@ from typing import Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.street_scheme import build_street_scheme, load_street_index
|
from app.services.street_scheme import StreetIndex, build_street_scheme, load_street_index
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -158,15 +161,26 @@ class ShowcaseRow:
|
||||||
# None штатно: у части сделок координаты нет, подставлять туда нечего.
|
# None штатно: у части сделок координаты нет, подставлять туда нечего.
|
||||||
lat: float | None = None
|
lat: float | None = None
|
||||||
lon: float | None = None
|
lon: float | None = None
|
||||||
|
# Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM).
|
||||||
|
# Саму схему строим только для показанных строк — см. `_schemes_for`.
|
||||||
|
has_street: bool = False
|
||||||
|
|
||||||
|
|
||||||
def completeness(row: ShowcaseRow) -> int:
|
def completeness(row: ShowcaseRow) -> int:
|
||||||
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..3).
|
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..4).
|
||||||
|
|
||||||
Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по
|
Считаем район/этаж/этажность и схему улицы: комнаты и площадь есть у всех
|
||||||
построению выборки, поэтому в оценке полноты они бесполезны.
|
кандидатов по построению выборки, поэтому в оценке полноты они бесполезны.
|
||||||
|
|
||||||
|
СХЕМА УЛИЦЫ — ТАКОЕ ЖЕ ВИДИМОЕ ПОЛЕ, как район. Строка без улицы рисует на
|
||||||
|
фронте полигон РАЙОНА вместо схемы улиц, то есть этого поля у неё просто
|
||||||
|
нет, и полнее строки с картой она быть не может. Это признак наличия
|
||||||
|
данных, а не величина ошибки: строка с большим отклонением, но со схемой,
|
||||||
|
показывается как есть.
|
||||||
"""
|
"""
|
||||||
return sum(x is not None for x in (row.district, row.floor, row.total_floors))
|
return int(row.has_street) + sum(
|
||||||
|
x is not None for x in (row.district, row.floor, row.total_floors)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||||||
|
|
@ -197,6 +211,7 @@ def build_row(
|
||||||
n_analogs: int,
|
n_analogs: int,
|
||||||
lat: float | None = None,
|
lat: float | None = None,
|
||||||
lon: float | None = None,
|
lon: float | None = None,
|
||||||
|
has_street: bool = False,
|
||||||
) -> ShowcaseRow | None:
|
) -> ShowcaseRow | None:
|
||||||
"""Кандидат → строка витрины, либо None если считать не из чего.
|
"""Кандидат → строка витрины, либо None если считать не из чего.
|
||||||
|
|
||||||
|
|
@ -251,6 +266,7 @@ def build_row(
|
||||||
n_analogs=n_analogs,
|
n_analogs=n_analogs,
|
||||||
lat=lat,
|
lat=lat,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
|
has_street=has_street,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -320,19 +336,27 @@ _INSERT_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict:
|
def _schemes_for(
|
||||||
|
db: Session,
|
||||||
|
index: StreetIndex,
|
||||||
|
chosen: list[ShowcaseRow],
|
||||||
|
addresses: dict[int, str | None],
|
||||||
|
) -> dict:
|
||||||
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
|
"""Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема.
|
||||||
|
|
||||||
Считаем после отбора, а не до: схема — это два пространственных запроса на
|
Считаем после отбора, а не до: схема — это два пространственных запроса на
|
||||||
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
|
сделку, и на двухстах кандидатах ради двадцати показанных это четыреста
|
||||||
лишних запросов в чужую базу.
|
лишних запросов в чужую базу.
|
||||||
|
|
||||||
НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор
|
НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы
|
||||||
по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина
|
входит наравне с районом и этажом. До 2026-08-31 здесь было записано
|
||||||
показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на
|
обратное — «схема на отбор не влияет», — и первой строкой витрины и первым
|
||||||
OSM. Схема — украшение строки, а не причина её показать.
|
раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо
|
||||||
|
схемы улиц рисовался полигон района. Отсутствие видимого поля не может
|
||||||
|
делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не
|
||||||
|
затронут: строка с большим отклонением, но со схемой, стоит в витрине как
|
||||||
|
есть.
|
||||||
"""
|
"""
|
||||||
index = load_street_index(db)
|
|
||||||
out = {}
|
out = {}
|
||||||
for row in chosen:
|
for row in chosen:
|
||||||
scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
|
scheme = build_street_scheme(db, index, addresses.get(row.deal_id))
|
||||||
|
|
@ -377,6 +401,10 @@ def refresh_landing_showcase_deals(
|
||||||
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
|
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
|
||||||
|
|
||||||
districts = _fetch_districts(db, [d.id for d in deals])
|
districts = _fetch_districts(db, [d.id for d in deals])
|
||||||
|
# Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак
|
||||||
|
# полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на
|
||||||
|
# прогон, дорогие пространственные запросы остались в `_schemes_for`.
|
||||||
|
street_index = load_street_index(db)
|
||||||
|
|
||||||
candidates: list[ShowcaseRow] = []
|
candidates: list[ShowcaseRow] = []
|
||||||
n_priced = 0
|
n_priced = 0
|
||||||
|
|
@ -408,6 +436,7 @@ def refresh_landing_showcase_deals(
|
||||||
# тип этого не поймает: обе величины float.
|
# тип этого не поймает: обе величины float.
|
||||||
lat=deal.lat,
|
lat=deal.lat,
|
||||||
lon=deal.lon,
|
lon=deal.lon,
|
||||||
|
has_street=street_index.lookup(deal.address) is not None,
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
n_incomplete += 1
|
n_incomplete += 1
|
||||||
|
|
@ -415,7 +444,7 @@ def refresh_landing_showcase_deals(
|
||||||
candidates.append(row)
|
candidates.append(row)
|
||||||
|
|
||||||
chosen = select_rows(candidates, limit)
|
chosen = select_rows(candidates, limit)
|
||||||
schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals})
|
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
|
||||||
|
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
db.execute(_DELETE_RUNS_SQL)
|
db.execute(_DELETE_RUNS_SQL)
|
||||||
|
|
|
||||||
|
|
@ -1129,6 +1129,88 @@ _SAMPLE_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── ВЫБОРКА ВРАЗБРОС (--spread scattered) ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ЗАЧЕМ. `ORDER BY id DESC` выше — это последние ВСТАВЛЕННЫЕ строки, а Росреестр
|
||||||
|
# грузится пачками по домам: соседние id это один дом и одна улица. Замер на
|
||||||
|
# проде 31.08.2026 по Екатеринбургу, выборка 200:
|
||||||
|
#
|
||||||
|
# последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
|
||||||
|
# случайные 200 : 129 разных улиц, максимум 6 с одной
|
||||||
|
#
|
||||||
|
# То есть заголовочная точность и вся витрина лэндинга считались по выборке
|
||||||
|
# втрое беднее случайной. Ошибка при этом не «шумит», а СМЕЩЕНА: пара новостроек
|
||||||
|
# ведёт себя иначе, чем город.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ НЕ СТАЛО УМОЛЧАНИЕМ. Замороженный регресс-гейт сравнивает прогоны между
|
||||||
|
# собой и требует от дефолтного пути побайтово того же SQL (см. докстринг
|
||||||
|
# `_sample_sql`). Менять умолчание значило бы обнулить сравнимость всей истории
|
||||||
|
# замеров. Режим выбирается явно.
|
||||||
|
#
|
||||||
|
# ПОЧЕМУ md5, А НЕ random(). `random()` даёт разную выборку на каждом прогоне —
|
||||||
|
# два замера подряд отличались бы и из-за правки, и из-за выборки, и разделить
|
||||||
|
# эти вклады было бы нечем. md5(id || seed) — псевдослучайный, но
|
||||||
|
# ВОСПРОИЗВОДИМЫЙ порядок: тот же seed → та же выборка.
|
||||||
|
_SAMPLE_SQL_SCATTERED = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ST_X(geom::geometry) AS lon,
|
||||||
|
ST_Y(geom::geometry) AS lat,
|
||||||
|
rooms,
|
||||||
|
price_per_m2 AS sold_ppm2,
|
||||||
|
price_rub,
|
||||||
|
deal_date,
|
||||||
|
area_m2,
|
||||||
|
address,
|
||||||
|
floor,
|
||||||
|
total_floors,
|
||||||
|
year_built,
|
||||||
|
house_type
|
||||||
|
FROM deals
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND geom IS NOT NULL
|
||||||
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
|
AND rooms IS NOT NULL
|
||||||
|
AND area_m2 IS NOT NULL
|
||||||
|
AND area_m2 > 0
|
||||||
|
AND deal_date >= CAST(:since AS date)
|
||||||
|
ORDER BY md5(CAST(id AS text) || CAST(:seed AS text))
|
||||||
|
LIMIT CAST(:sample AS integer)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_SAMPLE_SQL_SCATTERED_CITY = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ST_X(geom::geometry) AS lon,
|
||||||
|
ST_Y(geom::geometry) AS lat,
|
||||||
|
rooms,
|
||||||
|
price_per_m2 AS sold_ppm2,
|
||||||
|
price_rub,
|
||||||
|
deal_date,
|
||||||
|
area_m2,
|
||||||
|
address,
|
||||||
|
floor,
|
||||||
|
total_floors,
|
||||||
|
year_built,
|
||||||
|
house_type
|
||||||
|
FROM deals
|
||||||
|
WHERE source = 'rosreestr'
|
||||||
|
AND geom IS NOT NULL
|
||||||
|
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||||
|
AND rooms IS NOT NULL
|
||||||
|
AND area_m2 IS NOT NULL
|
||||||
|
AND area_m2 > 0
|
||||||
|
AND deal_date >= CAST(:since AS date)
|
||||||
|
AND city = CAST(:city AS text)
|
||||||
|
ORDER BY md5(CAST(id AS text) || CAST(:seed AS text))
|
||||||
|
LIMIT CAST(:sample AS integer)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi
|
# Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi
|
||||||
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
|
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
|
||||||
# is applied in Python for byte-for-byte fidelity with the estimator.
|
# is applied in Python for byte-for-byte fidelity with the estimator.
|
||||||
|
|
@ -1163,7 +1245,7 @@ _CITY_PPM2_BAND_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sample_sql(city: str | None) -> Any:
|
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
||||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||||
|
|
||||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||||
|
|
@ -1177,6 +1259,8 @@ def _sample_sql(city: str | None) -> Any:
|
||||||
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
|
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
|
||||||
slug — see the ``--city`` CLI help for the naming decision.
|
slug — see the ``--city`` CLI help for the naming decision.
|
||||||
"""
|
"""
|
||||||
|
if scattered:
|
||||||
|
return _SAMPLE_SQL_SCATTERED_CITY if city is not None else _SAMPLE_SQL_SCATTERED
|
||||||
if city is None:
|
if city is None:
|
||||||
return _SAMPLE_SQL
|
return _SAMPLE_SQL
|
||||||
return text(
|
return text(
|
||||||
|
|
@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float
|
||||||
|
|
||||||
|
|
||||||
def _load_sample(
|
def _load_sample(
|
||||||
db: Session, *, sample: int, since: str, city: str | None = None
|
db: Session,
|
||||||
|
*,
|
||||||
|
sample: int,
|
||||||
|
since: str,
|
||||||
|
city: str | None = None,
|
||||||
|
scattered: bool = False,
|
||||||
|
seed: str = "mera",
|
||||||
) -> list[DealSample]:
|
) -> list[DealSample]:
|
||||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||||
|
|
||||||
|
|
@ -1259,7 +1349,9 @@ def _load_sample(
|
||||||
}
|
}
|
||||||
if city is not None:
|
if city is not None:
|
||||||
params["city"] = city
|
params["city"] = city
|
||||||
rows = db.execute(_sample_sql(city), params).mappings().all()
|
if scattered:
|
||||||
|
params["seed"] = seed
|
||||||
|
rows = db.execute(_sample_sql(city, scattered), params).mappings().all()
|
||||||
out: list[DealSample] = []
|
out: list[DealSample] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
||||||
|
|
@ -2025,6 +2117,8 @@ def run_backtest(
|
||||||
rooms_tolerance: int,
|
rooms_tolerance: int,
|
||||||
holdout_split: bool = False,
|
holdout_split: bool = False,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
|
scattered: bool = False,
|
||||||
|
seed: str = "mera",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the full read-only backtest and return a metrics dict.
|
"""Drive the full read-only backtest and return a metrics dict.
|
||||||
|
|
||||||
|
|
@ -2044,7 +2138,7 @@ def run_backtest(
|
||||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour).
|
(byte-identical to the pre-oblast-D behaviour).
|
||||||
"""
|
"""
|
||||||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||||
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||||
|
|
||||||
matched_rows: list[tuple[float, float, int]] = []
|
matched_rows: list[tuple[float, float, int]] = []
|
||||||
|
|
@ -2118,6 +2212,8 @@ def run_backtest_full(
|
||||||
dump_fixture: str | None = None,
|
dump_fixture: str | None = None,
|
||||||
resolve_house_id: bool = False,
|
resolve_house_id: bool = False,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
|
scattered: bool = False,
|
||||||
|
seed: str = "mera",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||||
|
|
||||||
|
|
@ -2150,7 +2246,7 @@ def run_backtest_full(
|
||||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
est = _import_estimator_full()
|
||||||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed)
|
||||||
logger.info(
|
logger.info(
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||||
)
|
)
|
||||||
|
|
@ -2318,6 +2414,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
default="2025-06-01",
|
default="2025-06-01",
|
||||||
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
|
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--spread",
|
||||||
|
choices=("recent", "scattered"),
|
||||||
|
default="recent",
|
||||||
|
help="Как берётся выборка сделок. 'recent' (умолчание) — ORDER BY id DESC, "
|
||||||
|
"т.е. последние ВСТАВЛЕННЫЕ строки: Росреестр грузится пачками по домам, "
|
||||||
|
"поэтому такая выборка садится на несколько улиц (замер 31.08.2026 по ЕКБ: "
|
||||||
|
"38 улиц, до 22 сделок с одной). 'scattered' — воспроизводимый "
|
||||||
|
"псевдослучайный порядок по md5(id||seed): 129 улиц, максимум 6 с одной. "
|
||||||
|
"Умолчание оставлено 'recent' ради сравнимости с историей замеров — "
|
||||||
|
"замороженный регресс-гейт требует от дефолтного пути того же SQL.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--seed",
|
||||||
|
default="mera",
|
||||||
|
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
|
||||||
|
"прогона подряд отличаются только правкой, а не составом выборки.",
|
||||||
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
dump_fixture=args.dump_fixture,
|
dump_fixture=args.dump_fixture,
|
||||||
resolve_house_id=args.resolve_house_id,
|
resolve_house_id=args.resolve_house_id,
|
||||||
city=args.city,
|
city=args.city,
|
||||||
|
scattered=(args.spread == "scattered"),
|
||||||
|
seed=args.seed,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = run_backtest(
|
metrics = run_backtest(
|
||||||
|
|
@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
rooms_tolerance=args.rooms_tolerance,
|
rooms_tolerance=args.rooms_tolerance,
|
||||||
holdout_split=args.holdout_split,
|
holdout_split=args.holdout_split,
|
||||||
city=args.city,
|
city=args.city,
|
||||||
|
scattered=(args.spread == "scattered"),
|
||||||
|
seed=args.seed,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
88
tradein-mvp/backend/tests/test_backtest_sampling_spread.py
Normal file
88
tradein-mvp/backend/tests/test_backtest_sampling_spread.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания.
|
||||||
|
|
||||||
|
ЗАЧЕМ. `ORDER BY id DESC` берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр
|
||||||
|
грузится пачками по домам: соседние id — это один дом и одна улица. Замер на
|
||||||
|
проде 31.08.2026 по Екатеринбургу, выборка 200:
|
||||||
|
|
||||||
|
последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
|
||||||
|
случайные 200 : 129 разных улиц, максимум 6 с одной
|
||||||
|
|
||||||
|
Выборка втрое беднее случайной — а на ней стояла витрина лэндинга (20 строк с
|
||||||
|
ЧЕТЫРЁХ улиц, 16 из них с двух).
|
||||||
|
|
||||||
|
ЧТО ЭТОТ ТЕСТ ДЕРЖИТ. Две вещи, и вторая не менее важна первой:
|
||||||
|
|
||||||
|
1. Режим «вразброс» действительно меняет порядок и воспроизводим по seed.
|
||||||
|
2. УМОЛЧАНИЕ НЕ ТРОНУТО — дефолтный путь обязан отдавать ТОТ ЖЕ объект SQL.
|
||||||
|
Замороженный регресс-гейт сравнивает прогоны между собой; сменить умолчание
|
||||||
|
значило бы молча обнулить сравнимость всей истории замеров.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from scripts.backtest_estimator import (
|
||||||
|
_SAMPLE_SQL,
|
||||||
|
_SAMPLE_SQL_SCATTERED,
|
||||||
|
_SAMPLE_SQL_SCATTERED_CITY,
|
||||||
|
_sample_sql,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sql(obj) -> str:
|
||||||
|
return str(obj.text if hasattr(obj, "text") else obj)
|
||||||
|
|
||||||
|
|
||||||
|
class TestУмолчаниеНеТронуто:
|
||||||
|
def test_дефолтный_путь_отдаёт_тот_же_объект(self):
|
||||||
|
# Именно identity, а не равенство текста: докстринг `_sample_sql`
|
||||||
|
# обещает побайтовую неизменность, и «текст совпал» это обещание не
|
||||||
|
# проверяет — объект мог быть пересобран с другими bind-параметрами.
|
||||||
|
assert _sample_sql(None) is _SAMPLE_SQL
|
||||||
|
assert _sample_sql(None, scattered=False) is _SAMPLE_SQL
|
||||||
|
|
||||||
|
def test_умолчание_сортирует_по_id_и_не_знает_про_seed(self):
|
||||||
|
sql = _sql(_SAMPLE_SQL)
|
||||||
|
assert "ORDER BY id DESC" in sql
|
||||||
|
assert ":seed" not in sql, "в умолчание протёк параметр режима вразброс"
|
||||||
|
|
||||||
|
|
||||||
|
class TestРежимВразброс:
|
||||||
|
def test_выбирается_явно_и_меняет_порядок(self):
|
||||||
|
assert _sample_sql(None, scattered=True) is _SAMPLE_SQL_SCATTERED
|
||||||
|
assert _sample_sql("Екатеринбург", scattered=True) is _SAMPLE_SQL_SCATTERED_CITY
|
||||||
|
|
||||||
|
def test_порядок_воспроизводим_а_не_случаен(self):
|
||||||
|
# random() дал бы разную выборку на каждом прогоне, и два замера
|
||||||
|
# подряд отличались бы и из-за правки, и из-за состава выборки —
|
||||||
|
# разделить вклады было бы нечем.
|
||||||
|
for obj in (_SAMPLE_SQL_SCATTERED, _SAMPLE_SQL_SCATTERED_CITY):
|
||||||
|
sql = _sql(obj)
|
||||||
|
assert "md5(" in sql, "порядок обязан считаться от id и seed"
|
||||||
|
assert ":seed" in sql
|
||||||
|
assert re.search(r"\brandom\s*\(", sql) is None, (
|
||||||
|
"random() делает выборку неповторимой между прогонами"
|
||||||
|
)
|
||||||
|
assert "ORDER BY id DESC" not in sql
|
||||||
|
|
||||||
|
def test_городской_вариант_фильтрует_город_а_общий_нет(self):
|
||||||
|
assert "city = CAST(:city AS text)" in _sql(_SAMPLE_SQL_SCATTERED_CITY)
|
||||||
|
assert "city = CAST(:city AS text)" not in _sql(_SAMPLE_SQL_SCATTERED)
|
||||||
|
|
||||||
|
def test_оба_режима_отбирают_по_одним_и_тем_же_условиям(self):
|
||||||
|
# Иначе разница между режимами объяснялась бы не порядком, а другим
|
||||||
|
# набором сделок — и сравнение двух прогонов ничего бы не значило.
|
||||||
|
общие = [
|
||||||
|
"source = 'rosreestr'",
|
||||||
|
"geom IS NOT NULL",
|
||||||
|
"price_per_m2 BETWEEN",
|
||||||
|
"rooms IS NOT NULL",
|
||||||
|
"area_m2 > 0",
|
||||||
|
"deal_date >= CAST(:since AS date)",
|
||||||
|
]
|
||||||
|
база = _sql(_SAMPLE_SQL)
|
||||||
|
вразброс = _sql(_SAMPLE_SQL_SCATTERED)
|
||||||
|
for условие in общие:
|
||||||
|
assert условие in база, условие
|
||||||
|
assert условие in вразброс, условие
|
||||||
|
|
@ -31,6 +31,7 @@ def _row(
|
||||||
total_floors: int | None = 9,
|
total_floors: int | None = 9,
|
||||||
deal_date: date = date(2026, 1, 1),
|
deal_date: date = date(2026, 1, 1),
|
||||||
err_pct: float = 10.0,
|
err_pct: float = 10.0,
|
||||||
|
has_street: bool = True,
|
||||||
) -> ShowcaseRow:
|
) -> ShowcaseRow:
|
||||||
return ShowcaseRow(
|
return ShowcaseRow(
|
||||||
deal_id=deal_id,
|
deal_id=deal_id,
|
||||||
|
|
@ -45,6 +46,7 @@ def _row(
|
||||||
fact_rub=5_500_000,
|
fact_rub=5_500_000,
|
||||||
err_pct=err_pct,
|
err_pct=err_pct,
|
||||||
n_analogs=40,
|
n_analogs=40,
|
||||||
|
has_street=has_street,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +74,31 @@ def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
|
||||||
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
|
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_selection_prefers_row_with_street_scheme() -> None:
|
||||||
|
"""Строка без улицы не может быть самой полной: ей нечем нарисовать карту.
|
||||||
|
|
||||||
|
Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры
|
||||||
|
«Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо
|
||||||
|
схемы улиц у неё рисовался полигон РАЙОНА.
|
||||||
|
|
||||||
|
Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы
|
||||||
|
выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из
|
||||||
|
`completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
|
||||||
|
|
||||||
|
Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не
|
||||||
|
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
|
||||||
|
"""
|
||||||
|
no_street = _row(9, has_street=False, err_pct=75.7)
|
||||||
|
with_street = _row(8, err_pct=3.0)
|
||||||
|
|
||||||
|
chosen = select_rows([no_street, with_street], limit=2)
|
||||||
|
|
||||||
|
assert [r.deal_id for r in chosen] == [8, 9], (
|
||||||
|
"строка, которой нечем нарисовать карту, оказалась полнее строки со "
|
||||||
|
"схемой улиц — витрина открывается полигоном района"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_selection_is_deterministic_on_full_ties() -> None:
|
def test_selection_is_deterministic_on_full_ties() -> None:
|
||||||
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
|
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
|
||||||
rows = [_row(7), _row(9), _row(8)]
|
rows = [_row(7), _row(9), _row(8)]
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ services:
|
||||||
# Деградация источника в None — существующее состояние ответа (так же
|
# Деградация источника в None — существующее состояние ответа (так же
|
||||||
# ведёт себя таймаут), контракт API не меняется.
|
# ведёт себя таймаут), контракт API не меняется.
|
||||||
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
|
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
|
||||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
|
||||||
PUBLIC_URL: "https://gendsgn.ru/trade-in"
|
PUBLIC_URL: "https://gendsgn.ru/trade-in"
|
||||||
CORS_ORIGINS: '["https://gendsgn.ru"]'
|
CORS_ORIGINS: '["https://gendsgn.ru"]'
|
||||||
ENVIRONMENT: "production"
|
ENVIRONMENT: "production"
|
||||||
|
|
@ -373,7 +373,7 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
|
||||||
ENVIRONMENT: "production"
|
ENVIRONMENT: "production"
|
||||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||||
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
|
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
|
||||||
|
|
@ -432,7 +432,7 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
|
||||||
ENVIRONMENT: "production"
|
ENVIRONMENT: "production"
|
||||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||||
# Этот процесс — не scheduler_main; false на всякий случай, если общий
|
# Этот процесс — не scheduler_main; false на всякий случай, если общий
|
||||||
|
|
@ -468,7 +468,15 @@ services:
|
||||||
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
|
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
|
||||||
# Принудительно 0.0.0.0 чтобы Caddy достучался.
|
# Принудительно 0.0.0.0 чтобы Caddy достучался.
|
||||||
HOSTNAME: "0.0.0.0"
|
HOSTNAME: "0.0.0.0"
|
||||||
BACKEND_URL: "http://backend:8000" # internal SSR
|
# ИМЯ ОБЯЗАНО БЫТЬ ОДНОЗНАЧНЫМ. Оба продукта назвали свой сервис
|
||||||
|
# `backend`, и оба подключены к общей сети gendesign_shared —
|
||||||
|
# изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6
|
||||||
|
# и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает
|
||||||
|
# 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и
|
||||||
|
# подписи разброса, то есть страница про точность без доказательств.
|
||||||
|
# Отказ тихий: fetch не бросает, приходит валидный 401.
|
||||||
|
# `tradein-backend` — container_name, он однозначен.
|
||||||
|
BACKEND_URL: "http://tradein-backend:8000" # internal SSR
|
||||||
depends_on: [backend]
|
depends_on: [backend]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,57 @@ const V3_DIR = "_components/v3";
|
||||||
const GENERATED = ["ekb-districts.ts"];
|
const GENERATED = ["ekb-districts.ts"];
|
||||||
const GENERATED_MARK = "СГЕНЕРИРОВАН";
|
const GENERATED_MARK = "СГЕНЕРИРОВАН";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ПОДСТРАНИЦЫ ПЕРИМЕТРА тоже под гейтом, а не только корень и v3-секции.
|
||||||
|
*
|
||||||
|
* 31.08.2026 завели `/business`, и ревьюер заметил, что новая ПУБЛИЧНАЯ
|
||||||
|
* страница чисел ничем не сторожится: сканер знал ровно два места — корневой
|
||||||
|
* `page.tsx` и каталог `_components/v3`. Чисел там тогда не было, но правило
|
||||||
|
* держалось на том, что автор о нём помнил.
|
||||||
|
*
|
||||||
|
* Каталоги перечисляются ОБХОДОМ, а не списком: список — снимок, и следующую
|
||||||
|
* страницу в него снова забудут вписать. Обход находит её сам.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* ЮРИДИЧЕСКИЕ СТРАНИЦЫ ПОД ЭТОТ ГЕЙТ НЕ ПОПАДАЮТ — и это не послабление.
|
||||||
|
*
|
||||||
|
* Гейт ищет ФОРМУ величины: «14,5 %», «42 700», «150 ₽». Тексты оферты,
|
||||||
|
* политики и возврата состоят из ссылок на законы, и по форме они те же самые:
|
||||||
|
*
|
||||||
|
* Закон РФ от 07.02.1992 № 2300-1 «О защите прав потребителей»
|
||||||
|
* Федеральный закон от 27.07.2006 № 152-ФЗ
|
||||||
|
* ст. 18.1 152-ФЗ
|
||||||
|
*
|
||||||
|
* Отличить номер закона от замера рынка регулярным выражением нельзя, а
|
||||||
|
* подгонять под них исключения по одному значило бы дырявить сам сканер.
|
||||||
|
* Правило, которое гейт защищает, к этим страницам и не относится: там числа
|
||||||
|
* ЦИТИРУЮТСЯ из нормативных актов, а не утверждаются о рынке.
|
||||||
|
*
|
||||||
|
* Список ЯВНЫЙ и короткий: молчаливое расширение (например «все страницы с
|
||||||
|
* длинным текстом») однажды укроет и маркетинговую.
|
||||||
|
*/
|
||||||
|
const LEGAL_PAGES = new Set(["oferta", "privacy", "refund", "docs"]);
|
||||||
|
|
||||||
|
const PAGE_DIRS = readdirSync(ROOT, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.isDirectory() &&
|
||||||
|
e.name !== "__tests__" &&
|
||||||
|
e.name !== "_components" &&
|
||||||
|
!LEGAL_PAGES.has(e.name),
|
||||||
|
)
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const SUBPAGES = PAGE_DIRS.flatMap((dir) =>
|
||||||
|
readdirSync(join(ROOT, dir))
|
||||||
|
.filter((name) => /^page\.tsx?$/u.test(name))
|
||||||
|
.map((name) => `${dir}/${name}`),
|
||||||
|
);
|
||||||
|
|
||||||
const V3_SOURCES = [
|
const V3_SOURCES = [
|
||||||
LANDING_ROOT,
|
LANDING_ROOT,
|
||||||
|
...SUBPAGES,
|
||||||
...readdirSync(join(ROOT, V3_DIR))
|
...readdirSync(join(ROOT, V3_DIR))
|
||||||
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
|
.filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name))
|
||||||
.sort()
|
.sort()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { B2B_URL } from "../content";
|
|
||||||
import styles from "../landing-v3.module.css";
|
import styles from "../landing-v3.module.css";
|
||||||
import { PublicLink } from "./PublicLink";
|
import { PublicLink } from "./PublicLink";
|
||||||
|
|
||||||
|
|
@ -57,9 +56,9 @@ export function InnerHeader() {
|
||||||
</PublicLink>
|
</PublicLink>
|
||||||
|
|
||||||
<div className={styles.hdrRight}>
|
<div className={styles.hdrRight}>
|
||||||
<a href={B2B_URL} className={styles.hdrBizLink}>
|
<PublicLink to="business" className={styles.hdrBizLink}>
|
||||||
Для бизнеса
|
Для бизнеса
|
||||||
</a>
|
</PublicLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,20 @@
|
||||||
* а добавляет одну точку входа с тарифами и порядком возврата.
|
* а добавляет одну точку входа с тарифами и порядком возврата.
|
||||||
*
|
*
|
||||||
* ССЫЛКИ — КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` —
|
* ССЫЛКИ — КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` —
|
||||||
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса»
|
* отдельный экран проверки, а не на якорь формы в герое. Все переходы —
|
||||||
* стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг,
|
* обычным `<a>` через `PublicLink`, потому что `next/link` подставляет
|
||||||
* то есть пункт не работал. Все переходы — обычным `<a>` через `PublicLink`,
|
* basePath (см. его шапку).
|
||||||
* потому что `next/link` подставляет basePath (см. его шапку).
|
*
|
||||||
|
* «МЕРА ДЛЯ БИЗНЕСА» ВЕДЁТ НА `/business`, А НЕ НА `B2B_URL` (31.08.2026).
|
||||||
|
* Абсолютный адрес B2B-контура заканчивался формой входа `/trade-in/login` —
|
||||||
|
* посетителю публичного сайта предлагали залогиниться в аккаунт, которого у
|
||||||
|
* него нет. Теперь пункт ведёт на публичную страницу связи, а ссылка на вход
|
||||||
|
* живёт внутри неё, второстепенной.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
B2B_URL,
|
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
REGION_NAME,
|
REGION_NAME,
|
||||||
SUPPORT_EMAIL,
|
SUPPORT_EMAIL,
|
||||||
|
|
@ -70,9 +74,9 @@ export function FooterV3() {
|
||||||
<PublicLink to="articles" className={styles.ftrLink}>
|
<PublicLink to="articles" className={styles.ftrLink}>
|
||||||
Статьи
|
Статьи
|
||||||
</PublicLink>
|
</PublicLink>
|
||||||
<a href={B2B_URL} className={styles.ftrLink}>
|
<PublicLink to="business" className={styles.ftrLink}>
|
||||||
МЕРА для бизнеса
|
МЕРА для бизнеса
|
||||||
</a>
|
</PublicLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@
|
||||||
* был `CityPicker`).
|
* был `CityPicker`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { B2B_URL } from "../../content";
|
|
||||||
import styles from "../../landing-v3.module.css";
|
import styles from "../../landing-v3.module.css";
|
||||||
import { PublicLink } from "../PublicLink";
|
import { PublicLink } from "../PublicLink";
|
||||||
|
|
||||||
|
|
@ -87,9 +86,9 @@ export function HeaderV3() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className={styles.hdrRight}>
|
<div className={styles.hdrRight}>
|
||||||
<a href={B2B_URL} className={styles.hdrBizLink}>
|
<PublicLink to="business" className={styles.hdrBizLink}>
|
||||||
Для бизнеса
|
Для бизнеса
|
||||||
</a>
|
</PublicLink>
|
||||||
<a href="#check" className={styles.hdrCta}>
|
<a href="#check" className={styles.hdrCta}>
|
||||||
Проверить квартиру
|
Проверить квартиру
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
155
tradein-mvp/frontend/src/app/mera-public/business/page.tsx
Normal file
155
tradein-mvp/frontend/src/app/mera-public/business/page.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
|
import { InnerHeader } from "../_components/InnerHeader";
|
||||||
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
|
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||||
|
import {
|
||||||
|
B2B_URL,
|
||||||
|
SUPPORT_EMAIL,
|
||||||
|
SUPPORT_TELEGRAM_LABEL,
|
||||||
|
SUPPORT_TELEGRAM_URL,
|
||||||
|
} from "../content";
|
||||||
|
import styles from "../landing-v3.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «МЕРА для бизнеса» — страница связи, а не вход.
|
||||||
|
*
|
||||||
|
* ЗАЧЕМ ЗАВЕДЕНА. Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале
|
||||||
|
* вели на `B2B_URL`. Реальная цепочка на проде: 308 → `/trade-in` → 307 →
|
||||||
|
* `/trade-in/v2` → клиентский редирект → `/trade-in/login`. Посетитель
|
||||||
|
* публичного сайта упирался в форму «Логин / Пароль» от аккаунта, которого у
|
||||||
|
* него нет и завести который на этом экране нельзя. Владелец просил починить
|
||||||
|
* это дважды.
|
||||||
|
*
|
||||||
|
* ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Ни цен, ни сроков ответа, ни объёмов, ни числа
|
||||||
|
* клиентов: таких величин никто не мерил, а на странице они читались бы как
|
||||||
|
* обязательство (то же решение, что в `docs/page.tsx` про часы поддержки).
|
||||||
|
* Формы заявки тоже нет — принимать её в продукте нечем: анонимной ручки для
|
||||||
|
* B2B-лида не существует, и форма, которая никуда не пишет, хуже честной
|
||||||
|
* ссылки на живой канал. Каналы взяты из `content.ts` — те же, что в подвале и
|
||||||
|
* в документах, а не заведены для этой страницы отдельно.
|
||||||
|
*
|
||||||
|
* ССЫЛКА НА ВХОД ОСТАВЛЕНА, но второстепенной: у кого доступ уже есть, тот
|
||||||
|
* пришёл именно за ней, и убрать её значило бы чинить один дефект вторым.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "МЕРА для бизнеса — связаться",
|
||||||
|
// Тот же noindex, что у остального публичного дерева (см. layout.tsx и
|
||||||
|
// docs/page.tsx): страница открыта по адресу, но в выдачу не идёт.
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BusinessPage() {
|
||||||
|
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InnerHeader />
|
||||||
|
<main id="content" className={styles.artPage} tabIndex={-1}>
|
||||||
|
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
|
||||||
|
<PublicLink to="home" className={styles.artCrumbLink}>
|
||||||
|
Главная
|
||||||
|
</PublicLink>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span aria-current="page">Для бизнеса</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className={styles.artPageHead}>
|
||||||
|
<div className={styles.repEyebrow}>ДЛЯ БИЗНЕСА</div>
|
||||||
|
<h1 className={styles.artPageTitle}>МЕРА для бизнеса</h1>
|
||||||
|
<p className={styles.artPageLead}>
|
||||||
|
Оценка квартир по живым объявлениям под своей учётной записью —
|
||||||
|
для агентства, застройщика или компании, которая принимает квартиры
|
||||||
|
в зачёт.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.docsSection} aria-labelledby="biz-who">
|
||||||
|
<h2 id="biz-who" className={styles.docsH2}>
|
||||||
|
Кому это нужно
|
||||||
|
</h2>
|
||||||
|
<p className={styles.artP}>
|
||||||
|
Тем, кто считает не одну квартиру, а много и регулярно: агентствам
|
||||||
|
недвижимости, отделам продаж застройщиков, компаниям с программами
|
||||||
|
трейд-ин. Расчёт идёт по тем же источникам, что и бесплатная
|
||||||
|
проверка на сайте, — но под учётными записями вашей команды, с
|
||||||
|
сохранением истории и месячным лимитом на аккаунт.
|
||||||
|
</p>
|
||||||
|
<p className={styles.artP}>
|
||||||
|
Если задача разовая — квартира одна, и нужно понять цену, — идти
|
||||||
|
сюда незачем: бесплатная проверка на сайте отвечает на этот вопрос
|
||||||
|
без договорённостей и без регистрации.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.docsSection} aria-labelledby="biz-diff">
|
||||||
|
<h2 id="biz-diff" className={styles.docsH2}>
|
||||||
|
Чем отличается от бесплатной проверки
|
||||||
|
</h2>
|
||||||
|
<div className={styles.docsLinks}>
|
||||||
|
<PublicLink to="estimate" className={styles.docsLinkCard}>
|
||||||
|
<span className={styles.docsLinkTitle}>Бесплатная проверка</span>
|
||||||
|
<span className={styles.docsLinkNote}>
|
||||||
|
Одна квартира, здесь и сейчас, без регистрации. Картина рынка
|
||||||
|
вокруг дома: сколько похожих квартир продаётся рядом и как давно
|
||||||
|
висят объявления
|
||||||
|
</span>
|
||||||
|
</PublicLink>
|
||||||
|
<span className={styles.docsLinkCard}>
|
||||||
|
<span className={styles.docsLinkTitle}>Бизнес-контур</span>
|
||||||
|
<span className={styles.docsLinkNote}>
|
||||||
|
Закрытый контур с доступом по учётной записи: история своих
|
||||||
|
расчётов, учётные записи команды, месячный лимит на аккаунт.
|
||||||
|
Доступ выдаётся вручную, после разговора, — самостоятельной
|
||||||
|
регистрации в нём нет
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.artP}>
|
||||||
|
Что именно войдёт в работу — обсуждается: задача разная у
|
||||||
|
агентства, у застройщика и у трейд-ин программы. Поэтому на этой
|
||||||
|
странице нет ни тарифа, ни срока: назвать их до разговора значило бы
|
||||||
|
их придумать.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.docsSection} aria-labelledby="biz-contact">
|
||||||
|
<h2 id="biz-contact" className={styles.docsH2}>
|
||||||
|
Как связаться
|
||||||
|
</h2>
|
||||||
|
<p className={styles.artP}>
|
||||||
|
Напишите в любой из каналов — коротко, что за компания и какая
|
||||||
|
задача: сколько адресов, как часто, что должно быть на выходе.
|
||||||
|
Этого хватает, чтобы ответить по существу с первого письма.
|
||||||
|
</p>
|
||||||
|
<div className={styles.artKv}>
|
||||||
|
<div className={styles.artKvRow}>
|
||||||
|
<span className={styles.artKvKey}>ПОЧТА</span>
|
||||||
|
<span className={styles.artKvVal}>
|
||||||
|
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{telegramHref ? (
|
||||||
|
<div className={styles.artKvRow}>
|
||||||
|
<span className={styles.artKvKey}>TELEGRAM</span>
|
||||||
|
<span className={styles.artKvVal}>
|
||||||
|
<a href={telegramHref} target="_blank" rel="noreferrer">
|
||||||
|
{SUPPORT_TELEGRAM_LABEL}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className={styles.artFootnote}>
|
||||||
|
Уже работаете с нами? <a href={B2B_URL}>Вход в бизнес-контур</a> —
|
||||||
|
по логину и паролю, которые вам выдали.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<FooterV3 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -195,6 +195,10 @@ export const PUBLIC_ROUTES = {
|
||||||
articles: "/articles",
|
articles: "/articles",
|
||||||
articleHowTo: "/articles/kak-ocenit-kvartiru",
|
articleHowTo: "/articles/kak-ocenit-kvartiru",
|
||||||
docs: "/docs",
|
docs: "/docs",
|
||||||
|
// Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026
|
||||||
|
// взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур
|
||||||
|
// и заканчивалась формой входа для аккаунта, которого у посетителя нет.
|
||||||
|
business: "/business",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Канонический публичный origin сервиса — база для canonical-ссылок. */
|
/** Канонический публичный origin сервиса — база для canonical-ссылок. */
|
||||||
|
|
@ -209,6 +213,12 @@ export const PUBLIC_ORIGIN = "https://meraocenka.ru";
|
||||||
* осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её
|
* осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её
|
||||||
* из текущего хоста было бы неверно.
|
* из текущего хоста было бы неверно.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* ⚠️ ЭТО ВХОД В ЗАКРЫТЫЙ КОНТУР, А НЕ ВИТРИНА. Цепочка редиректов приводит на
|
||||||
|
* `/trade-in/login` — форму «Логин / Пароль». Человеку с улицы показывать её
|
||||||
|
* нельзя, поэтому шапка и подвал ведут на `PUBLIC_ROUTES.business`, а этот
|
||||||
|
* адрес остался ссылкой «у меня уже есть доступ» внутри той страницы.
|
||||||
|
*/
|
||||||
export const B2B_URL = "https://gendsgn.ru/trade-in/";
|
export const B2B_URL = "https://gendsgn.ru/trade-in/";
|
||||||
|
|
||||||
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */
|
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ const PUBLIC_SHORT_PATHS = [
|
||||||
"/articles",
|
"/articles",
|
||||||
"/articles/kak-ocenit-kvartiru",
|
"/articles/kak-ocenit-kvartiru",
|
||||||
"/docs",
|
"/docs",
|
||||||
|
"/business",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isPublicPath(rawPath: string): boolean {
|
export function isPublicPath(rawPath: string): boolean {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue