Compare commits

...
Sign in to create a new pull request.

81 commits

Author SHA1 Message Date
698576e77b fix(tradein/scraper): per-city SERP location for oblast city-sweep (#12) (#2525)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m1s
Deploy Trade-In / deploy (push) Successful in 6m47s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-07-13 21:41:07 +00:00
67b50bf713 feat(tradein/ui): admin links to audit + analytics in UserMenu (#2524)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m54s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 55s
Admin-only «Аудит доступа» → /admin/audit and «Активность» → /admin/analytics links in the UserMenu dropdown. Navigates via API_BASE_URL prefix (Topbar convention, basePath-safe). Pilot menu unchanged.
2026-07-13 21:16:29 +00:00
c61ed5a96e fix(tradein/avito): catch sidecar timeout/transport errors in SERP fetch → graceful banned, not crash (#2523)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / deploy (push) Successful in 7m48s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-07-13 20:59:39 +00:00
e61c2debe5 fix(tradein/audit): exclude /api/v1/admin/* from api_request events (#2522)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Admin dashboard traffic to /api/v1/admin/* no longer logged as api_request (would pollute behavior analytics); login events kept for IP audit.
2026-07-13 20:55:16 +00:00
c904fbf94e feat(tradein/admin): audit + activity dashboards (Feature 2/3 UI) (#2521)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m56s
Deploy Trade-In / deploy (push) Successful in 46s
Admin-only /admin/audit (accounts × IP/device × searches) and /admin/analytics (KPIs, daily chart, top searches/paths/accounts) over the user_events read API. Gated like /scrapers; 403 → NoAccessScreen.
2026-07-13 20:45:34 +00:00
9fd6396fbc feat(tradein/admin): audit + analytics read API over user_events (#2520)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 10s
Admin-only read API: GET /admin/audit/accounts, /admin/audit/accounts/{username}, /admin/analytics over user_events. Read-only, empty-safe, RBAC via central gate.
2026-07-13 20:26:58 +00:00
8b7af92ee3 fix(tradein/estimator): displayed analog cards match n_analogs + cross-source dedup (#2518)
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Failing after 54s
Deploy Trade-In / build-backend (push) Successful in 1m9s
2026-07-13 20:19:20 +00:00
2c5b30f8c9 fix(tradein/auth): RouteGuard session/error screens with retry CTA (#2516)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-07-13 20:19:12 +00:00
d530602fbd fix(tradein/quota): per-user quota override + no-burn on empty estimate (#2517)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-07-13 20:18:42 +00:00
32037af6de feat(tradein/audit): request-audit middleware + estimate_request logging → user_events (#2519)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 51s
RequestAuditMiddleware logs api_request + deduped login per authenticated /api/* request; estimate() logs estimate_request with address. Fire-and-forget, never blocks/breaks the request. Writes to user_events (Feature 2/3 capture).
2026-07-13 20:09:22 +00:00
5647c2b40e feat(tradein/db): user_events table — unified login/IP audit + behavior event log (#2515)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 52s
Append-only user_events table (event_type, username, ip_address inet, user_agent, path, method, estimate_id, payload jsonb, created_at) + 4 indexes. Foundation for login/IP audit + behavior analytics. Resurrects dropped audit_log design.
2026-07-13 19:47:26 +00:00
749c96cd0c feat(tradein/ui): Telegram feedback button in Topbar (env-gated) (#2514)
All checks were successful
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 47s
Env-gated Telegram feedback button next to UserMenu; renders only when NEXT_PUBLIC_FEEDBACK_TG_URL is set. safeUrl() sanitized, outside RBAC nav.
2026-07-13 19:44:00 +00:00
987dce11bc fix(tradein/pdf): МЕРА branding + 4-page report, no empty pages (#2513)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 54s
2026-07-13 18:04:07 +00:00
b5477362f0 fix(tradein/auth): restore brusnika (user2) pilot access (#2512)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 1m7s
Deploy / changes (push) Successful in 15s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-worker (push) Successful in 53s
Deploy / build-backend (push) Successful in 54s
Deploy Trade-In / test (push) Successful in 1m20s
Deploy Trade-In / build-backend (push) Successful in 43s
Deploy / deploy (push) Successful in 1m56s
2026-07-13 15:33:36 +00:00
60c21ae2a5 feat(tradein/lead): persist 152-ФЗ proof-of-consent (LOW audit R2 #9) (#2511)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Successful in 58s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / build-frontend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m20s
2026-07-12 22:13:36 +00:00
3bd0873230 fix(tradein/scheduler): re-enable deactivate_stale_domklik now that domclick sweeps run (LOW audit R2 #10) (#2510)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m1s
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-12 22:12:12 +00:00
5e73b35206 fix(tradein/location-coef): re-center coefficient band so a strong central location ~= 1.0 (LOW audit R2 #7a) (#2509)
Some checks failed
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:12:10 +00:00
69ca746673 fix(tradein/v2): location coefficient informational + price-history clarity (LOW audit R2 #7c/#8) (#2508)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 22:11:08 +00:00
2cd24ecfbc fix(tradein/scrapers): clamp implausible listing/publish dates (LOW audit R2 #6) (#2507)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:11:06 +00:00
92371e7d9e fix(tradein/pdf): scale offer-page rent by room count (LOW audit R2 #5) (#2506)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:11:04 +00:00
7fa52bb3ad fix(tradein/estimator): honest repair-note + ratio_basis label; doc EKB-calibrated constants (LOW audit R2 #1-4) (#2505)
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 22:11:03 +00:00
348be445e3 fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 5m52s
Non-EKB Свердловская-область addresses no longer snap into Екатеринбург.

Root cause: geocode()/suggest() ran EKB-only local tiers (geoportal/cadastral) before the oblast-aware external providers, with no city gate — a non-EKB street+house colliding with an EKB building resolved to EKB coords. Fix gates the local tiers via _names_non_ekb_city (reuses SVERDLOVSK_OBLAST_CITIES, word-boundary + district-prefix exclusion + \bекатеринбург\b short-circuit). EKB path byte-identical. Deep-review APPROVE, CI green, live-verified (Nominatim returns correct Н.Тагил coords once local tiers skip).

Follow-up (main): geocode_cache purge of ~323 poisoned rows.
2026-07-12 21:08:49 +00:00
bot-backend
8fdcd8df62 fix(tradein/geocoder): word-boundary EKB short-circuit + gate suggest() + gazetteer (#11)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 56s
Deep-review fast-follows on the non-EKB city gate:

1. Word-boundary the "екатеринбург" short-circuit in _names_non_ekb_city
   (new _EKATERINBURG_RE = \bекатеринбург\b, .search() instead of substring
   `in`). A bare-substring check matched "Екатеринбургское шоссе" (a real
   street in satellite towns like Pervouralsk) as if it named EKB, wrongly
   keeping EKB-only local tiers on for a non-EKB address. Still returns False
   for the existing homonym false-positive test case (word boundary is
   satisfied there — "екатеринбург" appears as a standalone token).

2. Gate suggest()'s Tier 1 (same root cause as geocode()): the EKB-only
   cadastral matchers _cadastral_house_match / _cadastral_forward_sync ran
   unconditionally, so a non-EKB oblast autocomplete query could surface an
   EKB building via street+house collision. Gated behind
   `not _names_non_ekb_city(query)`, mirroring geocode()'s use_local_ekb.
   External suggest tiers (DaData/Yandex/Nominatim) untouched — still the
   path for non-EKB autocomplete.

3. Extended SVERDLOVSK_OBLAST_CITIES with unambiguous, deal-heavy oblast
   cities: алапаевск, сухой лог, кушва, красноуральск, карпинск, нижняя
   тура, верхний тагил, нижние серги. Deliberately did NOT add "лесной"
   (reviewer flagged as reviewer-optional, left to judgment): DB check
   (ekb_geoportal_buildings) confirms a real EKB street named exactly
   "Лесной" (3 buildings) — an exact whole-word collision indistinguishable
   from ЗАТО Лесной by word-boundary matching alone (unlike "Серова"/"Серов",
   which are different word forms). Adding it would misclassify a bare
   EKB address ("Лесной, 5", no "Екатеринбург" mention) as non-EKB.

Tests: word-boundary EKB regression test, suggest() gating test, two new
gazetteer entries (Верхняя Пышма multi-word, Сухой Лог).
2026-07-13 00:06:19 +03:00
c98a879a79 Merge pull request 'fix(tradein/matching): Tier-2a city-guard для non-ЕКБ coord-less (п.2, defense-in-depth)' (#2502) from fix/tradein-oblast-tier2a-cityguard into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-12 21:04:28 +00:00
bot-backend
2ef3e42da1 fix(tradein/geocoder): explicit Екатеринбург wins over oblast-city homonym (#11)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Prod-cache verification (deep-review) found 3 false-positives: EKB addresses
that literally contain "екатеринбург" but also happen to contain a homonym
of another oblast city inside a neighborhood/posyolok name — ЖК "Заречный"
and пос. Сысерть, both administratively inside Ekaterinburg, not the ZATO
Zarechny / town Sysert they share a name with. These were gated off the
EKB-only local tiers; if Nominatim were momentarily down, a real EKB address
would return None — a latent regression not present before this PR.

Add an explicit-city short-circuit at the top of _names_non_ekb_city: if the
normalized address contains "екатеринбург", return False immediately (EKB
tiers stay on). Cannot affect the true-positive (non-EKB) cases, which never
contain "екатеринбург".
2026-07-12 23:50:49 +03:00
bot-backend
720cee2c8e fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 53s
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
geocode() ran EKB-only local tiers (2a _geoportal_house_match on
ekb_geoportal_buildings, 2c _cadastral_house_match, 2d
_cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the
already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX
+ region cross-check, shipped in c0cbdc2f). _parse_street_house drops the
city, so any non-EKB oblast street+house that collides with an EKB building
(e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited
to EKB coordinates.

Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES
gazetteer (minus Ekaterinburg) and word-boundary matching from
_has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly
names a different oblast city. Gate tiers 2a/2c/2d behind
`use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep
the exact same code path (byte-identical ordering/logic, only additive
gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which
already handle oblast-wide geocoding correctly.

Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows);
ekb_geoportal_buildings is 100% EKB by construction — gating loses no
meaningful local coverage for non-EKB cities.

Follow-up (not in this PR): geocode_cache has rows poisoned by the old
behavior (non-EKB addresses cached with EKB coordinates) — needs a purge.
2026-07-12 23:45:40 +03:00
73f932d60a Merge pull request 'feat(tradein): sber-index freshness monitor (п.1 observability)' (#2503) from fix/tradein-sber-freshness-monitor into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
2026-07-12 20:31:19 +00:00
e7c388dde7 Merge pull request 'refactor(tradein): консолидировать corridor/street-deals city-scope на _resolve_target_city (п.3)' (#2501) from fix/tradein-corridor-dedup-scope into main
Some checks failed
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 20:31:05 +00:00
bot-backend
07275c3c97 feat(tradein): монитор свежести СберИндекса (audit п.1)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
Добавляет sber_freshness_monitor по образцу deals_freshness_monitor:
staleness данных СберИндекса теперь видна на MONITOR-частоте, а не тонет
в per-estimate warning'ах estimator._load_sber_index_series (#audit-5a).

- app/tasks/sber_freshness_monitor.py: чистая evaluate_sber_freshness()
  (frozen-now, без БД) + check_sber_freshness() (один SELECT
  max(period_month) вторичного сегмента по региону, #R2-H1 фильтр как в
  эстиматоре; WARNING при stale, mark_done при алерте — это монитор, не
  сбой; mark_failed только при пустой таблице).
- app/services/product_handlers.py: _job_sber_freshness_monitor + Handler
  в build_product_handlers (run_in_executor, как deals-монитор).
- data/sql/180_seed_sber_freshness_monitor.sql: seed scrape_schedules
  (enabled, daily 09:00-10:00 UTC, lag_allowance_days=25).
- tests/test_sber_freshness_monitor.py: frozen-now (fresh/stale/границы) +
  FakeDB (fresh/stale/empty/кастомный lag) + свойства миграции + registry.

Порог алерта: sber_index_max_age_days (35) + lag_allowance (25) = 60д.
+25 — запас на инхерентный лаг публикации источника (1-2 мес), чтобы не
шуметь на штатном отставании. Прод 2026-07-12: max=2026-05-01, age=72д >
60 → alert=1.
2026-07-12 23:27:35 +03:00
bot-backend
d92ff5c3d3 fix(tradein/oblast): Tier-2a guard — coord-less non-ЕКБ карточка не матчит ЕКБ-alias
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s
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
Follow-up к #2500: закрывает Tier-2a-дыру, которую geo-guard Tier-2b не доставал.
Coord-less карточка, чей адрес РАЗРЕШАЕТ non-ЕКБ город обл.66 (resolve_city_token),
больше не матчит глобально-уникальный alias (ни по fingerprint, ни по
normalized_address) — иначе non-ЕКБ карточка баккетилась бы в одноимённый ЕКБ-дом и
корраптила ЕКБ-данные. При срабатывании guard'а обе alias-выборки пропускаются →
fall-through в New house (Tier-3 coord-gated, тоже пропускается).

normalize.py: + resolve_city_token() (город обл.66 или None), + EKB_CITY_TOKEN;
has_city_token переиспользует resolve_city_token.

EKB happy-path байт-в-байт: guard срабатывает ТОЛЬКО когда адрес называет non-ЕКБ
город И нет координат. ЕКБ-карточки (resolved city = екатеринбург) и доминирующие
bare/city-less coord-less карточки Avito (resolved None) идут Tier-2a/2b как раньше.

ВАЖНО (документировано в коде и отчёте): реальные Avito SERP-адреса — bare (без
city-токена; 2% из 5037 avito-alias'ов несут 'екатеринбург', 0% — oblast). Значит для
bare oblast-карточки resolve_city_token=None и guard дремлет: полное закрытие
bare-Tier-2a-остатка требует sweep-context/city-keyed aliases — отдельный follow-up,
вне scope, актуален лишь при включённом oblast-sweep. Zero prod-impact сегодня.
2026-07-12 23:26:01 +03:00
bot-backend
d31b1e01ee refactor(tradein): консолидировать corridor/street-deals city-scope на _resolve_target_city (п.3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
#2488 (_resolve_target_city + LOWER(d.city)=:target_city) смёржен НА ВЕРХ моего #2489
(substring :address ILIKE '%'||d.city||'%') → в _fetch_dkp_corridor образовался
двойной city-фильтр (benign, но избыточный + латентный ё/е edge в substring).

- estimator._fetch_dkp_corridor: убран мой substring-фильтр, оставлен #2488
  _resolve_target_city (словарь ~30 городов обл.66 вкл. ЕКБ + все sweep-города;
  city=None → фильтр не применяется — прежнее #2488-поведение). Убран unused :address bind.
- api/v1/trade_in.get_street_deals: substring заменён на тот же _resolve_target_city
  паттерн (консистентность; #2488 не трогал street-deals). city_filter — литерал,
  значение bind-параметром (не инъекция).

Live-verified: Ленина 2к ЕКБ → median 118052, 1 город (байт-в-байт как substring).
Regression-гейт байт-зелёный (коридор заморожен в фикстуре), 53 теста, ruff clean.
2026-07-12 23:18:02 +03:00
51473fc841 Merge pull request 'fix(tradein): oblast avito-sweep city-slug + Tier-2b geo-guard (R2, latent)' (#2500) from fix/tradein-scrapers-oblast into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Successful in 2m32s
Deploy Trade-In / deploy (push) Successful in 1m37s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 59s
Deploy Trade-In / build-backend (push) Successful in 1m47s
2026-07-12 19:42:29 +00:00
c3d0f86f37 Merge pull request 'fix(tradein/v2): honesty + a11y — histogram outlier-guard, scatter-релейбл, mobile-notice, keyboard-nav (R2)' (#2499) from fix/tradein-frontend-honesty into main
Some checks failed
Deploy Trade-In / changes (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
2026-07-12 19:42:15 +00:00
bot-backend
219cdfdbcc fix(tradein/v2): честность гистограмм и оси scatter + мобильный guard + a11y графика
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 1m23s
- Гистограммы флагман-карточки (ОБЪЯВЛЕНИЯ) и ДКП/сделок биннятся по
  outlier-guarded пулу (guardPriceOutliers().clean) — консистентно с числом
  «выброс исключён» и спредом на той же карточке. Раньше bins8 спанил
  [min,max] ВКЛЮЧАЯ выброс 872k, схлопывая реальные аналоги в левые бины.
- Ось детального scatter «СРОК ПРОДАЖИ» → «СРОК НА РЫНКЕ»: для активных лотов
  x = дней на рынке (today−listing_date, нижняя граница), а не измеренный
  срок продажи. Обновлены title/aria-label/подзаголовок карточки.
- <1024px: honest desktop-only notice вместо нечитаемого ~25%-scaled артборда
  (client-side width guard, ранний return после всех хуков; десктоп не тронут).
- WCAG 2.1.1: точки истории цен теперь фокусируемы с клавиатуры
  (role=button / tabIndex / Enter+Space / onFocus-onBlur), мышиный hover сохранён.
2026-07-12 22:38:29 +03:00
bot-backend
bf4ce1ba36 fix(tradein/oblast): city-slug в avito SERP + geo/city guard в Tier-2b матчинга
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m3s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#2487 avito oblast per-city sweep был dead-on-arrival: _parse_html дропал все
карточки без /ekaterinburg/ в source_url, поэтому oblast-sweep по городу вне ЕКБ
терял 100% выдачи. Kept-slug теперь параметризуется через AvitoScraper.target_city_slug
(дефолт "ekaterinburg" — ЕКБ-поведение неизменно), прокинут scheduler →
run_avito_city_sweep → AvitoScraper. avito_serp_ekb_only НЕ отключается.

Bug #2 (oblast bare-street mis-bucket): голые адреса 'ул. Ленина 100' в Н.Тагиле
мис-баккетились в одноимённые ЕКБ-дома через Tier-2b (match по глобально-уникальному
normalized_address без geo/city guard). Добавлен coarse-guard: coords present →
ST_DWithin ≤3км до geom дома; coords absent → требуется city-token (self-
disambiguating); иначе skip → консервативно New house вместо мис-матча. _insert_alias
при коллизии normalized_address больше не перебивает fingerprint чужого дома (иначе
guard деградировал бы в Tier-2a-дыру).

Оба дефекта латентные (oblast per-city schedules отключены) — правка делает
capability безопасной к включению, без влияния на прод сегодня.
2026-07-12 22:38:11 +03:00
519b482cdd Merge pull request 'fix(tradein/rbac): энфорсить roles.yaml scope на всех non-admin путях (R2 H3 security)' (#2498) from fix/tradein-rbac-scope into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 54s
Deploy Trade-In / deploy (push) Successful in 1m2s
Deploy Trade-In / build-backend (push) Successful in 1m12s
2026-07-12 19:28:38 +00:00
e4bb2fc2e3 Merge pull request 'fix(tradein/lead): phone digit-guard, убрать dead landing-литерал, 152-ФЗ audit (R2)' (#2497) from fix/tradein-lead-hardening into main
Some checks failed
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
2026-07-12 19:28:26 +00:00
bot-backend
85321d1d1a fix(tradein/rbac): энфорсить roles.yaml scope на всех non-admin путях (R2 H3)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 52s
rbac_guard гейтил только /api/v1/admin/* → revoked (role=expired, roles.yaml
paths:[] deny:/**) и узко-скоупленные аккаунты сохраняли полный non-admin
API-доступ (напр. POST /api/v1/search — экспорт листингов + estimate-quota).
is_path_allowed (roles.yaml paths/deny) существовал, но НЕ вызывался (0 callers).

Fix: после admin-гейта вызываем is_path_allowed(role, external_path) для всех
non-bootstrap путей. roles.yaml globs — внешние (Caddy срезал /trade-in), поэтому
восстанавливаем внешний путь (_EXTERNAL_PREFIX + path). Bootstrap-пути /me и
/brand/* исключены — expired ДОЛЖЕН получить role=expired через /me (trial-экран)
и брендинг; без исключения trial-UX сломался бы (expired paths:[] → 403 на /me).
На сбой парса — fail-open + громкий лог (не лочим платящего pilot из-за конфиг-бага).

roles.yaml НЕ меняю — только энфорсю уже задекларированную политику. pilot/admin/
analyst доступ сохранён (verified), expired теперь 403 на non-admin API.
Тесты: +5 scope-кейсов (expired denied search/trade-in, allowed me/brand; pilot/
admin/analyst preserved) — 31 passed. Тест-harness middleware — mirror, обновлён.
2026-07-12 22:26:14 +03:00
b1ac551451 Merge pull request 'fix(tradein): ingest/scheduler integrity — year sanity, propagate geo-guard, dkp_import counters + DO UPDATE (R2)' (#2496) from fix/tradein-ingest-scheduler-integrity into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 50s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / deploy (push) Successful in 4m0s
2026-07-12 19:20:10 +00:00
032fd95a7e Merge pull request 'fix(tradein): image_sanitizer — reject decompression-bomb до декода (R2 security)' (#2495) from fix/tradein-image-dos into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 19:19:39 +00:00
bot-backend
0583fb0efd fix(trade-in): усилить валидацию /lead и почистить контракт (#2376)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 9s
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) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 58s
- [MED] phone: маска r"^[+]?[\d\s().-]{5,32}$" делала цифры опциональными
  ("(()) -- .." проходило) — добавлен field_validator, требующий 10-15
  реальных цифр (re.sub(r"\D","")), формат-толерантность сохранена.
- [MED] source: убран мёртвый литерал "landing" из Literal — воронка
  недостижима (rbac_guard закрывает /lead 401 без X-Authenticated-User,
  публичного лендинг-роута нет), контракт больше не обещает невозможное.
- [LOW] 152-ФЗ: захват client_ip (X-Forwarded-For -> peer) и
  _CONSENT_POLICY_VERSION в audit-лог; live-схема trade_in_leads не имеет
  колонок под IP/policy/consent-snapshot -> TODO на follow-up миграцию
  (out of scope), durable proof-of-consent gap задокументирован.

Тесты: +digit-free/too-few-digits phone 422, +source=landing 422,
estimate-тест переведён на source=result. 9/9 pass, ruff clean.
2026-07-12 22:19:20 +03:00
a3dfa2a99f Merge pull request 'fix(tradein/pdf): честность отчёта — убрать фейк-сроки, хардкод EKБ, противоречивый дисконт (R2)' (#2494) from fix/tradein-pdf-honesty into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 19:19:08 +00:00
bot-backend
20bdc7dec8 test(tradein/pdf): обновить insufficient-page тест под city-aware № отчёта
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (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 / backend-tests (pull_request) Successful in 56s
test_insufficient_data_page_contains_address_and_report_num ассертил старый
захардкоженный «EKБ-» (mixed-script баг). Оценка без target_address → город не
распознан → нейтральный префикс «МЕРА» (#pdf-honesty). Обновлён ассерт: «МЕРА-»
присутствует, «EKБ-» отсутствует.
2026-07-12 22:12:42 +03:00
bot-backend
6487813ec5 fix(tradein/ingest): 4 data-integrity дефекта в бэкфилле годов и импорте ДКП
All checks were successful
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 / backend-tests (pull_request) Successful in 57s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
A. ДОМ.РФ year backfill: авторитетный commission_year больше не экранируется
   невозможным существующим year_built. Валидное существующее значение
   выигрывает (COALESCE-семантика #2013 сохранена), но NULL/impossible
   (< 1850, > текущий+2, 0) заменяется валидным commission_year → zhkh_year.
   parse_int_field получил sanity-гейт [min,max] — мусорный commission_year
   не попадает в staging (7 таких строк в текущем staging).

B. propagate_listings_year: добавлен link-consistency guard. Раньше копировал
   houses.year_built на listings по house_id_fk без проверки — перепривязанный
   FK впрыскивал чужой когортный год. Теперь пропагация только если координаты
   объявления в пределах 500м от геометрии дома (ST_DWithin), либо (без коорд.)
   консервативный address-фоллбек по short_address. Live: блокирует 1 из 10
   текущих кандидатов (>500м mislink).

C. rosreestr_dkp_import: per-row INSERT-ошибки отделены от dedup-skip. Раньше
   except инкрементил тот же batch_skipped, что и легитимный ON CONFLICT — сбой
   маскировался под дедуп и run рапортовал success. Отдельный rows_errored +
   гейт по доле ошибок (> 5% → run FAILED, не silent-green).

D. rosreestr_dkp_import: ON CONFLICT DO NOTHING → DO UPDATE изменяемых сырых
   фактов Росреестра (price/area/rooms/floor/year/deal_date/...), чтобы
   исправленный/переопубликованный квартал обновлялся. Обогащение
   (lat/lon/geom/geocode_tried_at и пр.) не в SET-списке — не затирается.
   IS DISTINCT FROM guard сохраняет идемпотентность resume; RETURNING (xmax=0)
   отличает insert от update (rows_inserted vs rows_updated).
2026-07-12 22:12:31 +03:00
bot-backend
1c9f0e1161 fix(tradein/photos): защита от pixel-flood DoS в image_sanitizer
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 51s
sanitize_image декодировал изображение целиком (img.load) ДО ресайза,
а Pillow MAX_IMAGE_PIXELS оставался дефолтным (~89 MP). Хорошо сжимаемый
≤10 MB аплоад с заявленными ~89-178 MP разворачивался в raw RGB буфер на
сотни MB до thumbnail — OOM-kill backend'а (768 MiB, #2214), усиление ×12
слотов на estimate.

Defense in depth:
- _MAX_PIXELS = 40 MP: проверка img.size (из хедера, без декода) ДО img.load;
  превышение → ImageSanitizationError без загрузки полного буфера.
- Image.MAX_IMAGE_PIXELS понижен до 40 MP — Pillow сам бросает
  DecompressionBombError на любом декод-пути в обход явной проверки.
- Явный except Image.DecompressionBombError + re-raise ImageSanitizationError
  (byte/pixel cap) до широкого except — грациозный reject, не 500.
- _MAX_BYTES backstop (endpoint уже режет 10 MB → 413, но сервис standalone).

Контракт caller'а не меняется: reject → ImageSanitizationError → HTTP 400.
Тест tests/services/test_image_sanitizer.py: happy-path, resize, pixel-flood
без декода (load не вызывается), byte-backstop, граница cap, garbage bytes.
2026-07-12 22:12:12 +03:00
0f4ed2f6e9 Merge pull request 'fix(tradein/estimator): СберИндекс только вторичка + confidence dispersion-ceiling (R2 H1+H2)' (#2493) from fix/tradein-estimator-sber-confidence into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 56s
Deploy Trade-In / build-backend (push) Successful in 1m15s
Deploy Trade-In / deploy (push) Successful in 57s
2026-07-12 19:06:43 +00:00
bot-backend
27d1b09c72 fix(trade-in-pdf): убрать три ложных утверждения из PDF-отчёта
Some checks failed
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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 / backend-tests (pull_request) Failing after 1m0s
Отчёт печатал измерения, которых не делал:

1. Выдуманный «срок продажи 4–118 дней». _days_on_market_range при
   отсутствии реальных days_on_market (~48% оценок) возвращал хардкод
   (4, 118), а обложка и страница объявлений рисовали его как
   измеренный срок экспозиции. Теперь функция возвращает None, и оба
   call-site'а не рисуют срок (show_days=False) — как уже делает
   страница сделок.

2. № отчёта хардкодил «EKБ» для любой оценки — объект в Серове /
   Нижнем Тагиле получал екатеринбургский код на обложке, в шапках и
   футере. Префикс теперь выводится из target_address (ЕКБ / НТ / СЕР /
   …); неизвестный город → нейтральный «МЕРА», а не ложный «EKБ».

3. Статичный «сделки на 10–18% ниже» противоречил рассчитанному в том
   же PDF «−N%». Совет на обложке и баннер на странице сделок теперь
   ссылаются на реальный _discount_pct (тот же, что chip «−N%»), с
   нейтральной формулировкой когда дисконт неизвестен. Удалён ложный
   хвост «(Екатеринбург, 2026)».

Тесты: +11 кейсов (suppression (4,118), city-aware № отчёта,
computed-discount вместо «10–18%»). Все 23 проходят, ruff чисто.
2026-07-12 22:04:17 +03:00
bot-backend
c1a01fdbf9 fix(tradein/estimator): СберИндекс — только вторичка + confidence dispersion-ceiling (R2 H1+H2)
All checks were successful
CI / changes (pull_request) Successful in 13s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 1m4s
H1 (деньги, каждая оценка): time-adjust ДКП-коридора брал СберИндекс `residential_real_estate_prices`,
а для обл.66 это 100% «Первичный рынок» (новостройки) → коррекция направленно противоположна
вторичке (первичка Jan→May +0.89% vs вторичка −0.39%). Fix: SBER_COEFF_DASHBOARDS →
(real_estate_deals, dinamika-tsen-obyavlenii) — обе вторичка; + segment-guard в SQL
(`segment ILIKE '%вторичн%'`) как defense-in-depth. Live-verified: остаются только Вторичный-серии.

H2 (честность): `_compute_confidence` ветка «≥4 адреса» не имела потолка разброса → пул с ±45%
IQR + «расширили радиус из-за нехватки данных» уходил как «medium» вопреки объяснению. Fix:
medium требует IQR/median < 0.35; + force-low при fallback-расширении с разбросом > 0.30.

Regression-гейт: перегенерён baseline (dedup OFF, как в гейте) — ровно 1 из 277 фикстур-кейсов
medium→low (высокодисперсный, справедливо): calibration.medium.n 2→1 (mape 14.64→6.99 —
ненадёжный ушёл), low.coverage 81.82→81.88. Δ минимальна и обоснована. 127 тестов зелёные.

Stale-СберИндекс (72д) — НЕ трогаю в коде: time-adjust валидно ре-базит Jan→May; сброс потерял
бы валидную коррекцию. Реальный gap операционный (pull не гонялся с 19.06) — в scheduler/ops.
2026-07-12 22:03:59 +03:00
fb123bd97e Merge pull request 'fix(tradein/estimator): non-EKB headline из deals + geo-bound Tier-S analog leak' (#2492) from fix/tradein-oblast-nonekb-headline into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 56s
2026-07-12 18:49:08 +00:00
bot-backend
f9ae6f0c53 fix(tradein/estimator): non-EKB headline из deals + geo-bound Tier-S analog leak
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
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 / backend-tests (pull_request) Successful in 1m3s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Валидация вскрыла: не-ЕКБ headline over-priced — analog Tier-S (same-building) fallback БЕЗ geo-предиката матчил одноимённую улицу+дом в ДРУГОМ городе → тянул ЕКБ-листинги (40191 EKB/~0 non-EKB) с distance_m=0. Серов ask-head 186k vs sold 30k; Н.Тагил 187k vs deal-corridor 89k.

Part 1 (Tier-S geo-bound, ~4624): + ST_DWithin(geom, subject, radius) в address-prefix fallback (зеркалит Tier-W). >99.97% листингов с coords → режет только cross-city false-positives.
Part 2 (deals-headline-fallback): _fetch_dkp_corridor widen на city-wide при street-sample <3 (gated city!=екатеринбург); _price_from_inputs: при median_ppm2<=0 (нет листингов) + anchor_tier None + dkp_raw>=3 → headline из deal-corridor, confidence=low, n_analogs=0, expected_sold=None.

Deep-review  APPROVE (no 🔴/🟠; ~10 downstream stages traced coherent). EKB byte-green regression-гейт (fallback требует median_ppm2<=0, невозможно для frozen fixture; Part 1 SQL не в replay). Тесты: Н.Тагил 0-листингов+corridor85911 → headline=85911 (не 0/186k); EKB-control dense → headline из листингов (fallback не срабатывает). Full suite 2418 passed.

Follow-ups (🟡): fallback без city-gate (sparse EKB n/a→deals, safe); api_analog_tier мислейбл 'city'; Part1/2a integration-test; EKB backtest post-deploy (rule #7).
2026-07-12 21:46:38 +03:00
3293df8e14 Merge pull request 'feat(tradein/backtest): --city per-city валидация точности + corridor city-parity' (#2491) from fix/tradein-oblast-d-backtest into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 46s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 50s
2026-07-12 17:51:12 +00:00
4c56c5189a Merge pull request 'fix(tradein/estimator): резолвить target-город коридора из payload.address (C2 hotfix)' (#2490) from fix/tradein-oblast-c2fix-corridor-city into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / test (push) Has been cancelled
2026-07-12 17:50:19 +00:00
bot-backend
25a865f27b feat(tradein/backtest): --city per-city валидация + corridor city-parity
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Has been skipped
D validation harness для oblast-точности:
- --city '<точное deals.city>' → scope выборки по deals.city; default None = байт-идентично (тот же _SAMPLE_SQL object, literal identity).
- per-city PPM2 band из deal_city_price_bands (fallback global, 0 доп-запросов при None).
- corridor city-parity: backtest _fetch_dkp_corridor теперь передаёт city=_resolve_target_city(deal.address) — зеркалит прод C2 (был cross-city contaminated). NB: меняет и default-run live-числа (не fixture-replay) — intended catch-up к проду.
- _compute_confidence подтверждён comp-count-based (без EKB-хардкода).
- +10 unit-тестов. Регресс-гейт байт-зелёный (replay не гоняет corridor).

code-review  APPROVE. Frozen fixture/baseline не тронуты. Live per-city backtest прогоню после деплоя.
2026-07-12 20:49:04 +03:00
bot-backend
69f8b50934 docs(tradein): исправить GET-rehydrate comment — row.address=geo.full_address (city-stripped), corridor unscoped для не-ЕКБ (known limitation)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
2026-07-12 20:48:25 +03:00
bot-backend
7801ac6cc1 fix(tradein/estimator): резолвить target-город коридора из payload.address (C2 hotfix)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 50s
C2 city-scope коридора был NO-OP для не-ЕКБ: estimate_quality резолвил город из geo.full_address, но геокодер РОНЯЕТ город для не-ЕКБ («Ленина, 1» вместо «Нижний Тагил, Ленина, 1») → target_city=None → коридор либо unscoped, либо None. Verified на проде: Н.Тагил corridor=None.

Фикс: резолвить город из payload.address (город есть) с fallback на canonical/geo; коридор матчить тоже по payload.address. GET: row.address приоритетнее canonical.

Verified прод-диагностикой: corridor(address=payload.address, city=resolve) → Н.Тагил=9, ЕКБ=15 (было: Н.Тагил=None). EKB regression-гейт байт-зелёный. geocoder full_address city-strip (display «Ленина,1») → cosmetic, E.
2026-07-12 20:36:08 +03:00
5d7c3e1c30 Merge pull request 'fix(tradein/estimator): city-scope ДКП-коридора + oblast city-tokens/bbox' (#2488) from fix/tradein-oblast-c2-estimator-scope into main
All checks were successful
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / deploy (push) Successful in 59s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
2026-07-12 17:21:55 +00:00
6936558f6f Merge pull request 'fix(tradein): city-scope ДКП-запросы — убрать oblast-контаминацию (C1+C2)' (#2489) from fix/tradein-oblast-city-scope-corridor-ratio into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-12 17:20:31 +00:00
bot-backend
f631af4de3 fix(tradein): city-scope ДКП-запросы — убрать oblast-контаминацию (C1+C2)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 51s
Миграция 177 залила +47183 ДКП-сделки по всей Свердловской обл. (66, 368 городов,
deals.city заполнен). Потребители deals матчат по имени улицы БЕЗ city-фильтра →
одноимённые улицы дешёвых городов области контаминируют ЕКБ-числа.

C1 — street-deals + ДКП-коридор (клиент видит СЕЙЧАС):
- estimator._fetch_dkp_corridor + api/v1/trade_in.get_street_deals: добавлен city-скоуп
  `city IS NOT NULL AND :address ILIKE '%'||city||'%'` (канонический deals.city как
  подстрока целевого адреса — self-normalizing, адрес всегда содержит город).
- Live-repro (Ленина, 2к, 12мес): median 52 908 → 118 052 ₽/м² (было занижение ~−55%).

C2 — дневной пересчёт asking→sold ratio (imminent, «выкупная» −29%):
- tasks/asking_to_sold_ratio: deal_side + deal_global скоупятся на ЕКБ (asking-сторона =
  listings покрыты скрейпом только по ЕКБ; в listings нет колонки city). Константа
  _ASKING_CITY_PATTERN. ask-стороны не трогаем.
- Live-repro (rooms=2): sold_median all-city 82 593 (ratio 0.622) → EKB 116 858
  (ratio 0.880). Предотвращает обвал «выкупной» на след. пересчёте.
- Когда появятся oblast-листинги (B1/B2) → заменить на per-city ratio через `district`.

Тесты: subset-тест 080↔refresh (mirror _drop_segment_guard прецедента для city-guard) +
позитивный тест скоупа; 125 passed вкл. regression-гейт (байт-зелёный — коридор заморожен
в фикстуре), expected_sold, idor, 781. Regression-гейт не затронут (dkp_raw — frozen kwarg).
2026-07-12 20:18:36 +03:00
bot-backend
4a579e74e0 Merge remote-tracking branch 'refs/remotes/forgejo/main' into fix/tradein-oblast-c2-estimator-scope
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
# Conflicts:
#	tradein-mvp/backend/app/services/estimator.py
2026-07-12 20:14:17 +03:00
bot-backend
b93d26990b fix(tradein/estimator): city-scope ДКП-коридора + oblast city-tokens/bbox
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 53s
Чинит доказанную кросс-город контаминацию коридора: street-name-матч без city тянул одноимённые улицы всех городов обл. («Ленина» = Н.Тагил+Лесной+Серов+ЕКБ...).

- _resolve_target_city(address) — word-boundary поверх geocoder.SVERDLOVSK_OBLAST_CITIES (F, ~30 городов), district-guard («мкр Заречный» = район, не город-ЗАТО).
- _fetch_dkp_corridor: +param city → AND LOWER(d.city)=CAST(:target_city AS text). Проводка в estimate_quality (dadata.canonical > geo) + trade_in.py GET rehydrate. city=None → прежнее поведение (fallback).
- _CITY_TOKENS + _DEDUP_ADDR_NOISE_WORDS → derive из SVERDLOVSK_OBLAST_CITIES (было 6 EKB-городов) → same-house dedup для non-EKB.
- bbox fast-path → geocoder.is_within_oblast66_bbox.
- docstring: коридор НЕ чисто advisory (клампит headline + radius-floor при n>=clamp_min_n).

EKB regression-гейт байт-зелёный (коридор upstream от frozen _price_from_inputs replay). Deep-review ⚠️APPROVE (no блокеров). Follow-ups (task #10): DB-derived city-set (Лесной 1103 uncovered), asking_to_sold_ratio city-scope, backtest city-parity.
2026-07-12 20:10:59 +03:00
90a6e4bfe9 Merge pull request 'feat(tradein/scraper): per-city sweep capability для 5 городов области (schedules disabled)' (#2487) from fix/tradein-oblast-b1-city-anchors into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Has been cancelled
2026-07-12 17:10:22 +00:00
bot-backend
cf91ac6b24 feat(tradein/scraper): per-city sweep capability для 5 городов области (schedules disabled)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 52s
B1: avito/cian/yandex city-sweep по городам Свердловской обл. (Н.Тагил/Каменск/Первоуральск/В.Пышма/Серов). Schedules засеяны DISABLED — rollout вручную по одному городу (proxy = 1 живой прокси).

- pipeline.py: CITY_ANCHORS (5) + get_city_anchors(slug) (None→EKB fallback).
- scheduler.py: sweep-jobs читают params.city → анкоры; wildcard-хендлеры *_city_sweep_* (source UNIQUE → per-city source, как deactivate_stale_*).
- geo.py: OBLAST66_BBOX + is_within_oblast66_bbox; avito/detail.py ingest-guard EKB→oblast66.
- price_brackets.py: 5 city-seed (reuse EKB). migration 179: 15 rows enabled=false, staggered, idempotent.
- +6 тестов. domclick (B2) не тронут.
2026-07-12 20:06:46 +03:00
74017b39c4 Merge pull request 'fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен' (#2486) from fix/tradein-dadata-clean-fallback-suggest into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 52s
2026-07-12 16:59:29 +00:00
bot-backend
e250869c9d fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
На проде у DaData-аккаунта выключена услуга CLEAN (Стандартизация) → /clean/address
отдаёт HTTP 403 «Feature CLEAN disabled», из-за чего house_fias_id (ключ join с
houses/ДОМ.РФ) и гео терялись, хотя /suggest их отдаёт бесплатно (token-only).

- enrich_address(): /clean → graceful fallback на /suggest (house-level кандидат);
  canonical_address/house_cadnum честно None (их даёт только CLEAN — не выдумываем).
- clean_address(): 403 «feature disabled» логируется отдельно от auth-rejection
  (токен валиден — refresh не поможет; чинить = включить услугу либо fallback).
- DadataSuggestion + _parse_suggestion: тянут house_fias_id/kladr_id/qc_geo.
- estimator: enrichment-вызов → enrich_address (алиас dadata_clean_address сохранён,
  это точка патча в 11 тестах — без churn).
- Тесты: feature-disabled лог, enrich clean-path/suggest-fallback/both-fail, новые поля.
  Проверено вживую на прод-кредах: clean→403, enrich→house_fias_id восстановлен.
2026-07-12 19:55:37 +03:00
b0b2c94ece Merge pull request 'fix(tradein/estimator): per-city ценовой band ДКП-сделок для всей Свердловской обл. (66)' (#2485) from fix/tradein-oblast-c1-price-band into main
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / deploy (push) Successful in 48s
2026-07-12 16:09:05 +00:00
bot-backend
e2502a0134 fix(tradein/estimator): per-city ценовой band ДКП-сделок для всей обл. 66
All checks were successful
CI Trade-In / frontend-checks (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 / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
Глобальный DEAL_MIN_PPM2=50k резал 46.6% не-ЕКБ сделок (легит дешёвые города: Североуральск p50=21.7k, Новоуральск 36k, Асбест 41k). Band стал per-city.

- migration 178: deal_city_price_bands (city PK, ppm2_min/max, n_deals), populate из deals (source=rosreestr, city<>Екатеринбург, count>=30), min=GREATEST(p1,8000) max=LEAST(p99,800000), idempotent upsert; HAVING-guard p99>=8000 (airtight против inverted band).
- estimator.py оба enforcement-пойнта city-aware с fallback на глобаль:
  - _fetch_dkp_corridor SQL: LEFT JOIN deal_city_price_bands + COALESCE(band, :global).
  - _is_plausible_deal(city, bands) + _fetch_deals: _load_city_price_bands (TTL-cache), SELECT city.
- ЕКБ исключён из таблицы → fallback на 50k/800k → EKB-сделки byte-identical (deals спеллит EKB только 'Екатеринбург', 49791).

EKB deal-level инвариант airtight; interior-EKB оценки идентичны. Регресс-гейт байт-зелёный. Follow-ups: periodic refresh band-таблицы; city-scoping _fetch_dkp_corridor (→ C2).
2026-07-12 19:07:05 +03:00
51952ebd70 Merge pull request 'fix(tradein/geocoder): раз-хардкод геокодера/DaData на всю Свердловскую обл. (region 66)' (#2484) from fix/tradein-oblast-f-geocoder into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
2026-07-12 15:35:27 +00:00
bot-backend
c0cbdc2f99 fix(tradein/geocoder): раз-хардкод геокодера/DaData на всю Свердловскую обл. (region 66)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
Геокодер был жёстко EKB-bound и ВЫБРАСЫВАЛ корректные non-EKB геокоды (bbox-accept-фильтры) → блокировал оценку любого адреса области. Обобщено до region-66.

- OBLAST66_BBOX + is_within_oblast66_bbox (superset EKB-tight, EKB не затронут); EKB Yandex Tier1/2 fast-path сохранён.
- Все accept-фильтры (Yandex/Nominatim forward+suggest, geocode() accept+typo) → oblast-66.
- City-prefix по word-boundary _has_oblast_marker (не substring): EKB-улицы 'Серова 27'/'Ирбитская 5' и др. больше не роняют 'Екатеринбург,'-префикс (был silent wrong-city баг).
- Two-pass tie-break в accept-циклах: tight-EKB кандидат приоритетнее → EKB-результат идентичен прежнему.
- Region cross-check (Nominatim address.state / Yandex AdministrativeAreaName) отсекает соседние области (Тюмень и др.); bbox-fallback когда region отсутствует.
- DaData suggest: region-параметр (hard-filter), убран no-op restrict_value.
- +26 тестов: OBLAST66 superset TIGHT, far-town accept, Тюмень reject, marker +/- (street-collision), DaData region body.

Foundation для A2 (геокодинг non-EKB сделок) и live-оценки адресов области.
2026-07-12 18:33:05 +03:00
fcc37c46ab fix(tradein/v2): аналитика — выровнять два графика в равную высоту (#2483)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Successful in 49s
Deploy Trade-In / build-frontend (push) Successful in 2m0s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 1m47s
2026-07-12 15:16:34 +00:00
95f0b0aa64 Merge pull request 'fix(tradein/ingest): раз-хардкод rosreestr DKP импорта на всю Свердловскую обл. (region 66)' (#2482) from fix/tradein-oblast-a1-ingest into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Successful in 31s
Deploy Trade-In / build-browser (push) Successful in 31s
Deploy Trade-In / test (push) Successful in 50s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-07-12 15:14:22 +00:00
bot-backend
752cb1044b fix(tradein/ingest): раз-хардкод rosreestr DKP импорта на всю обл. 66
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 51s
- scheduler.py import_rosreestr_dkp: снят фильтр city ILIKE '%катеринбург%', address из реального city источника, deals.region_code + новая deals.city заполняются (было: хардкод 'Екатеринбург,' + region_code NULL)
- migration 177: deals.city + индекс + бэкфилл существующих EKB-строк region_code=66/city
- guard city IS NOT NULL → address не NULL для ~15 null-city строк источника
- sync deploy/import-rosreestr.sh (ops-fallback) под тот же oblast-scope
- split dedup-теста: 077 (историческая) хранит EKB-фильтр, живой импорт — нет

Открывает +47183 не-ЕКБ сделок region 66, уже сидящих в источнике, ранее резавшихся на импорте.
2026-07-12 18:11:47 +03:00
338ccbb78f fix(tradein/v2): честность+консистентность — реконсиляция счётчика аналогов, убрать фейк-donut/линейку, outlier-guard, created_at, дисклеймеры (#2481)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m55s
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
2026-07-12 15:09:10 +00:00
a88e755403 fix(tradein/v2): аналитика — 2 графика в ряд, чтобы Цена×срок влезала на экран (#2480)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m55s
2026-07-12 14:56:08 +00:00
4f205bd7da fix(tradein/v2): polish АНАЛИТИКА — растянуть scatter, клэмп оси к 0, честные легенды/бакеты, убрать фейк-тренд (#2479)
All checks were successful
Deploy Trade-In / deploy (push) Successful in 43s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m53s
2026-07-12 14:37:05 +00:00
8dd35c01d8 feat(tradein/v2): карта вторички — сделки Росреестра + объявления по источникам (SourcesView) (#2478)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m3s
Deploy Trade-In / deploy (push) Successful in 45s
2026-07-12 13:26:55 +00:00
ed504cbbd9 fix(tradein): repoint rosreestr_poll to /data-sets/ + verify Content-Type (СДЕЛКИ dataset moved off /opendata) (#2477)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 49s
2026-07-12 13:21:58 +00:00
cc231946c2 feat(tradein): fill houses.year_built/material_walls from ДОМ.РФ КР1.1/1.2 + zhkh_year, propagate to listings (#2013) (#2476)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 2m46s
2026-07-12 13:12:07 +00:00
d22e0e00ae tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970) (#2475)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
2026-07-12 12:46:18 +00:00
346767ba90 Merge pull request 'test(rbac): user2 expected role expired after brusnika trial cutoff' (#2473) from fix/rbac-test-user2-expired into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 35s
Deploy Trade-In / test (push) Successful in 53s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy / deploy (push) Successful in 1m23s
Deploy Trade-In / deploy (push) Successful in 1m5s
2026-07-09 16:33:19 +00:00
bot-backend
f4c26d474a test(rbac): user2 expected role expired after brusnika trial cutoff
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m8s
CI / openapi-codegen-check (pull_request) Successful in 2m33s
CI / backend-tests (pull_request) Successful in 15m22s
Follow-up к смене user2 pilot→expired в roles.yaml. test_get_role_known_users
гонял user1..user10 == pilot циклом; user2 теперь expired. На PR #2472 этот
тест был skipped (paths-filter не матчил tradein-mvp/**, менялся только
auth/roles.yaml) → упал уже на полном деплое. Правка в обоих зеркалах
(tradein + main backend).
2026-07-09 19:16:59 +03:00
3fe1216317 Merge pull request 'fix(tradein/auth): expire brusnika (user2) trial access' (#2472) from fix/expire-brusnika-trial-access into main 2026-07-09 16:07:54 +00:00
116 changed files with 11552 additions and 1706 deletions

View file

@ -70,7 +70,7 @@ users:
admin: admin
kopylov: pilot
user1: pilot
user2: expired # «Брусника» — пробный доступ закрыт 2026-07-09 (аналогично praktika) — NoAccessScreen variant="trial"
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
user3: pilot
user4: pilot
user5: pilot

View file

@ -0,0 +1,280 @@
"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior
analytics dashboard data).
Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле вся ветка
`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py
(`_ADMIN_API_RE`, role != admin 403).
`user_events` (migration `184_user_events.sql`) может быть пустой (feature только
что задеплоена) все запросы ниже написаны так, чтобы на пустой таблице отдавать
пустые списки/нулевые счётчики, а не падать.
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.audit import (
AccountActivityEntry,
AccountDeviceEntry,
AccountDrilldown,
AccountIpEntry,
AccountSearchEntry,
AccountSummary,
AnalyticsByAccount,
AnalyticsDailyPoint,
AnalyticsDashboard,
AnalyticsSummary,
AnalyticsTopPath,
AnalyticsTopSearch,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/audit/accounts", response_model=list[AccountSummary])
async def list_accounts(
db: Annotated[Session, Depends(get_db)],
) -> list[AccountSummary]:
"""Список аккаунтов, по одной строке на username, с базовой сводкой активности."""
rows = (
db.execute(
text(
"""
SELECT username,
min(created_at) AS first_seen_at,
max(created_at) AS last_seen_at,
count(DISTINCT ip_address) AS distinct_ips,
count(DISTINCT user_agent) AS distinct_devices,
count(*) FILTER (WHERE event_type = 'login') AS login_count,
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
FROM user_events
GROUP BY username
ORDER BY last_seen_at DESC
"""
)
)
.mappings()
.all()
)
return [AccountSummary.model_validate(r) for r in rows]
@router.get("/audit/accounts/{username}", response_model=AccountDrilldown)
async def account_drilldown(
db: Annotated[Session, Depends(get_db)],
username: Annotated[str, Path(min_length=1, max_length=200)],
) -> AccountDrilldown:
"""Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность.
Неизвестный username НЕ 404, а пустой отчёт (все 4 списка == []): аудит не
подтверждает/опровергает существование аккаунта, просто нет событий.
"""
params = {"username": username}
ips = (
db.execute(
text(
"""
SELECT CAST(ip_address AS text) AS ip_address,
count(*) AS event_count,
min(created_at) AS first_seen,
max(created_at) AS last_seen
FROM user_events
WHERE username = :username AND ip_address IS NOT NULL
GROUP BY ip_address
ORDER BY last_seen DESC
"""
),
params,
)
.mappings()
.all()
)
devices = (
db.execute(
text(
"""
SELECT user_agent,
count(*) AS event_count,
min(created_at) AS first_seen,
max(created_at) AS last_seen
FROM user_events
WHERE username = :username AND user_agent IS NOT NULL
GROUP BY user_agent
ORDER BY last_seen DESC
"""
),
params,
)
.mappings()
.all()
)
searches = (
db.execute(
text(
"""
SELECT payload ->> 'address' AS address,
payload ->> 'area_m2' AS area_m2,
payload ->> 'rooms' AS rooms,
CAST(estimate_id AS text) AS estimate_id,
CAST(ip_address AS text) AS ip_address,
created_at
FROM user_events
WHERE username = :username AND event_type = 'estimate_request'
ORDER BY created_at DESC
LIMIT 200
"""
),
params,
)
.mappings()
.all()
)
recent_activity = (
db.execute(
text(
"""
SELECT event_type,
path,
method,
CAST(ip_address AS text) AS ip_address,
created_at
FROM user_events
WHERE username = :username
ORDER BY created_at DESC
LIMIT 200
"""
),
params,
)
.mappings()
.all()
)
return AccountDrilldown(
ips=[AccountIpEntry.model_validate(r) for r in ips],
devices=[AccountDeviceEntry.model_validate(r) for r in devices],
searches=[AccountSearchEntry.model_validate(r) for r in searches],
recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity],
)
@router.get("/analytics", response_model=AnalyticsDashboard)
async def analytics_dashboard(
db: Annotated[Session, Depends(get_db)],
days: Annotated[int, Query(ge=1, le=365)] = 30,
) -> AnalyticsDashboard:
"""Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты."""
summary_row = (
db.execute(
text(
"""
SELECT count(*) AS total_events,
count(DISTINCT username) AS distinct_users,
count(*) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS events_last_24h,
count(DISTINCT username) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS active_users_last_24h
FROM user_events
"""
)
)
.mappings()
.one()
)
daily_rows = (
db.execute(
text(
"""
SELECT date_trunc('day', created_at)::date AS day,
count(*) AS events,
count(DISTINCT username) AS users
FROM user_events
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
GROUP BY date_trunc('day', created_at)::date
ORDER BY day
"""
),
{"days": days},
)
.mappings()
.all()
)
top_searches_rows = (
db.execute(
text(
"""
SELECT payload ->> 'address' AS address, count(*) AS n
FROM user_events
WHERE event_type = 'estimate_request'
AND payload ->> 'address' IS NOT NULL
GROUP BY payload ->> 'address'
ORDER BY n DESC
LIMIT 20
"""
)
)
.mappings()
.all()
)
top_paths_rows = (
db.execute(
text(
"""
SELECT path, count(*) AS n
FROM user_events
WHERE event_type = 'api_request'
GROUP BY path
ORDER BY n DESC
LIMIT 20
"""
)
)
.mappings()
.all()
)
by_account_rows = (
db.execute(
text(
"""
SELECT username,
count(*) AS events,
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
max(created_at) AS last_seen
FROM user_events
GROUP BY username
ORDER BY events DESC
LIMIT 50
"""
)
)
.mappings()
.all()
)
return AnalyticsDashboard(
summary=AnalyticsSummary.model_validate(summary_row),
daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows],
top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows],
top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows],
by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows],
)

View file

@ -56,10 +56,12 @@ async def suggest_addresses(
limit: Annotated[int, Query(ge=1, le=15)] = 8,
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
) -> SuggestResponse:
"""Автокомплит адресов в пределах ЕКБ.
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
Bounded viewbox генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
ЕКБ (lon 60.40-60.85, lat 56.65-56.95) внутри него остаётся быстрым fast-path.
Пример:
/api/v1/geocode/suggest?q=Малышева

View file

@ -1,6 +1,6 @@
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
POST /api/v1/trade-in/lead контактная заявка с результата оценки (или лендинга):
POST /api/v1/trade-in/lead контактная заявка с результата оценки:
телефон + явное согласие на обработку персональных данных. Persist в
trade_in_leads. Notification (Telegram/email) вне scope: нет существующей
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
@ -10,11 +10,12 @@ persist + log; `notified_at` в таблице зарезервирован по
from __future__ import annotations
import logging
import re
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -28,13 +29,45 @@ router = APIRouter()
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
# Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр).
# Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный =
# 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны).
_PHONE_MIN_DIGITS = 10
_PHONE_MAX_DIGITS = 15
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
# только в audit-лог (#2497 TODO, теперь закрыт).
_CONSENT_POLICY_VERSION = "2026-07"
# Снимок точного текста согласия, который видит пользователь при отправке лида.
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
# будут нести устаревший snapshot под новой version-меткой.
_CONSENT_TEXT_SNAPSHOT = (
"Согласен(-на) на обработку персональных данных в соответствии с "
"Федеральным законом «О персональных данных» № 152-ФЗ"
)
class TradeInLeadInput(BaseModel):
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
estimate_id: UUID | None = None
consent: Literal[True]
source: Literal["result", "landing"] = "result"
# "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard
# (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а
# публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не
# обещал невозможную воронку (#2376). Вернуть, если появится public-роут.
source: Literal["result"] = "result"
@field_validator("phone")
@classmethod
def _phone_has_enough_digits(cls, value: str) -> str:
digits = len(re.sub(r"\D", "", value))
if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS):
raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits")
return value
@router.post("/lead")
@ -53,13 +86,31 @@ async def create_trade_in_lead(
raise HTTPException(status_code=404, detail="estimate not found")
user_agent = request.headers.get("user-agent")
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
# фронтящий Caddy), fallback — прямой peer. Персистится per-row в
# trade_in_leads.client_ip (migration 182), а не только в лог.
client_ip = request.headers.get("x-forwarded-for")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
elif request.client is not None:
client_ip = request.client.host
# 152-ФЗ proof-of-consent: client_ip / consent_policy_version /
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
row = (
db.execute(
text(
"""
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
INSERT INTO trade_in_leads (
estimate_id, phone, consent, source, user_agent,
client_ip, consent_policy_version, consent_text_snapshot
)
VALUES (
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
)
RETURNING CAST(id AS text), created_at
"""
),
@ -69,6 +120,9 @@ async def create_trade_in_lead(
"consent": payload.consent,
"source": payload.source,
"user_agent": user_agent,
"client_ip": client_ip,
"consent_policy_version": _CONSENT_POLICY_VERSION,
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
},
)
.mappings()
@ -78,10 +132,12 @@ async def create_trade_in_lead(
db.commit()
logger.info(
"trade_in_lead saved id=%s estimate_id=%s source=%s",
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
row["id"],
payload.estimate_id,
payload.source,
client_ip,
_CONSENT_POLICY_VERSION,
)
return {

View file

@ -11,12 +11,13 @@ from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import _client_ip
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
@ -45,6 +46,7 @@ from app.schemas.trade_in import (
from app.services import account_quota
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
@ -147,6 +149,7 @@ def _resolve_target_house_id(
@router.post("/estimate", response_model=AggregatedEstimate)
async def estimate(
payload: TradeInEstimateInput,
request: Request,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> AggregatedEstimate:
@ -180,9 +183,29 @@ async def estimate(
) from None
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
# тут: при гонке двух /estimate на used=MONTHLY_LIMIT-1 второй получит False.
if not account_quota.increment(db, x_authenticated_user):
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
# Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
# платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200.
if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user):
raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)
# Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись
# в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates).
# schedule_event сам никогда не raises — сбой аудита не должен ронять ответ.
schedule_event(
event_type="estimate_request",
username=x_authenticated_user or "",
ip=_client_ip(request),
user_agent=request.headers.get("user-agent"),
path=str(request.url.path),
method="POST",
estimate_id=str(result.estimate_id),
payload={
"address": payload.address,
"area_m2": payload.area_m2,
"rooms": payload.rooms,
},
)
return result
@ -244,6 +267,7 @@ def get_estimate(
_fetch_house_imv_anchor,
_fetch_price_trend,
_qc_geo_to_precision,
_resolve_target_city,
_source_counts,
)
@ -283,7 +307,16 @@ def get_estimate(
else None
)
dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f)
# (oblast C2): city-scope корридора на GET-rehydrate. NB: row.address здесь =
# geo.full_address (персистится на estimate-time), который РОНЯЕТ город для
# не-ЕКБ («Ленина, 1») → target_city=None → corridor unscoped/None для не-ЕКБ
# (KNOWN LIMITATION: POST-path чинит через payload.address; GET получит паритет
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
dkp_raw = _fetch_dkp_corridor(
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
)
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
imv_raw = _fetch_house_imv_anchor(
@ -1628,7 +1661,12 @@ def get_street_deals(
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
в import-rosreestr.sh).
"""
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
from app.services.estimator import (
_deal_to_analog,
_percentile,
_resolve_target_city,
extract_street_name,
)
now = datetime.now(tz=UTC)
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
@ -1663,16 +1701,25 @@ def get_street_deals(
area_min = area_m2 * (1.0 - area_tolerance)
area_max = area_m2 * (1.0 + area_tolerance)
# #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим
# город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66
# вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы
# др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не
# применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром.
target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
rows = (
db.execute(
text(
"""
f"""
SELECT address, area_m2, rooms, floor, total_floors,
price_rub, price_per_m2, deal_date, source
FROM deals
WHERE source = 'rosreestr'
AND address ILIKE :street_pattern
AND address ~* :street_regex
{city_filter}
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
@ -1683,6 +1730,7 @@ def get_street_deals(
{
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None,
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,

View file

@ -106,9 +106,7 @@ class Settings(BaseSettings):
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
# no-op (медиана не меняется).
estimate_imv_blend_enabled: bool = True
# При отсутствии IMV/Yandex no-op (медиана не меняется).
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
@ -117,9 +115,8 @@ class Settings(BaseSettings):
# комфорт 15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
# и tighter FSD-диапазон.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_same_building_anchor_enabled: bool = True
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
@ -138,8 +135,7 @@ class Settings(BaseSettings):
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
estimate_calibrated_pi_enabled: bool = True
# (проверено: 80.0% coverage на тех же 2366).
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
@ -171,7 +167,6 @@ class Settings(BaseSettings):
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
estimate_corridor_clamp_enabled: bool = True
estimate_corridor_clamp_min_n: int = 10
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
@ -181,9 +176,8 @@ class Settings(BaseSettings):
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_enabled: bool = True
# (избегаем ложных подъёмов) и ловит явный undershoot.
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_factor: float = 0.8
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
@ -215,7 +209,6 @@ class Settings(BaseSettings):
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
# ratio — не затрагиваются.
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
@ -225,8 +218,6 @@ class Settings(BaseSettings):
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
# Отдельный флаг: False → точно старое поведение (гейта нет).
estimate_sb_low_conf_gate_enabled: bool = True
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
@ -235,25 +226,12 @@ class Settings(BaseSettings):
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
# double-count в house_placement_history → шум в помесячной медиане тренда.
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
# False = старое поведение без дедупа (backward-compat).
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
estimate_price_trend_dedup_enabled: bool = True
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# False = старое поведение без принудительного понижения (для отката).
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
estimate_confidence_floor_no_analogs: bool = True
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
@ -261,8 +239,6 @@ class Settings(BaseSettings):
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
estimate_manual_review_enabled: bool = True
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
@ -274,12 +250,8 @@ class Settings(BaseSettings):
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
estimate_radius_dedup_enabled: bool = True
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
@ -302,7 +274,7 @@ class Settings(BaseSettings):
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
@ -367,11 +339,9 @@ class Settings(BaseSettings):
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
estimate_wide_corridor_disclosure_enabled: bool = True
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
@ -388,11 +358,6 @@ class Settings(BaseSettings):
cian_valuation_min_rub: float = 500_000
cian_valuation_max_rub: float = 500_000_000
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
# False = clip ДО weighting (старое поведение). Дефолт True.
estimate_sb_clip_after_weight: bool = True
# ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
@ -410,8 +375,6 @@ class Settings(BaseSettings):
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
# За флагом: False → точно текущее поведение (backward-compatible).
estimate_quarter_index_enabled: bool = True
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —

View file

@ -0,0 +1,71 @@
"""RequestAuditMiddleware — пишет `api_request` (+ дедуплицированный `login`)
события в `user_events` для каждого аутентифицированного `/api/*` запроса.
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
ответ клиенту) и через fire-and-forget `schedule_event` сбой аудита никогда
не влияет на HTTP-ответ.
"""
from __future__ import annotations
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from app.core.ratelimit import _client_ip
from app.services.user_events import schedule_event, should_log_login
logger = logging.getLogger(__name__)
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
# циклическую зависимость.
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
class RequestAuditMiddleware(BaseHTTPMiddleware):
"""Логирует активность аутентифицированных пользователей в `user_events`."""
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request)
try:
username = request.headers.get("x-authenticated-user")
path = request.url.path
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
ip = _client_ip(request)
ua = request.headers.get("user-agent")
# Общий behavior/activity-поток — каждый authenticated API-запрос.
# /api/v1/admin/* исключаем: это ops-действия (просмотр самих
# дашбордов аудита/аналитики), а не поведение пилота — иначе
# запросы дашборда зашумляют top_paths и счётчики активности.
# login ниже логируем всегда (вход админа с IP — валидный аудит).
if not path.startswith("/api/v1/admin/"):
schedule_event(
event_type="api_request",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=request.method,
)
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
# на (юзер, IP, устройство).
if should_log_login(username, ip, ua):
schedule_event(
event_type="login",
username=username,
ip=ip,
user_agent=ua,
path=path,
method=request.method,
)
except Exception:
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
return response

View file

@ -23,12 +23,13 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
from app.core.auth import get_role
from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.ratelimit import RateLimitMiddleware
from app.core.request_audit import RequestAuditMiddleware
from app.observability.sentry_scrub import scrub_pii_event
logger = logging.getLogger(__name__)
@ -108,6 +109,14 @@ app = FastAPI(
# X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
_EXTERNAL_PREFIX = "/trade-in"
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
@app.middleware("http")
@ -160,6 +169,39 @@ async def rbac_guard(
status_code=403,
content={"detail": "admin only"},
)
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
try:
allowed = is_path_allowed(role, external_path)
except Exception:
logger.exception(
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
username,
path,
external_path,
)
allowed = True
if not allowed:
logger.info(
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
username,
role,
path,
external_path,
)
return JSONResponse(
status_code=403,
content={"detail": "forbidden for role"},
)
return await call_next(request)
@ -172,6 +214,8 @@ app.add_middleware(
)
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
app.add_middleware(RateLimitMiddleware)
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
app.add_middleware(RequestAuditMiddleware)
@app.get("/health")
@ -181,6 +225,7 @@ def health() -> dict[str, str]:
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])

View file

@ -0,0 +1,146 @@
"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics).
`user_events` (migration `184_user_events.sql`) unified append-only событийный лог
(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы
`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py).
"""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class AccountSummary(BaseModel):
"""Одна строка списка `GET /audit/accounts` — сводка по одному username."""
model_config = ConfigDict(from_attributes=True)
username: str
first_seen_at: datetime
last_seen_at: datetime
distinct_ips: int
distinct_devices: int
login_count: int
request_count: int
search_count: int
class AccountIpEntry(BaseModel):
"""Одна строка `ips` в drilldown `GET /audit/accounts/{username}`."""
model_config = ConfigDict(from_attributes=True)
ip_address: str | None = None
event_count: int
first_seen: datetime
last_seen: datetime
class AccountDeviceEntry(BaseModel):
"""Одна строка `devices` в drilldown `GET /audit/accounts/{username}`."""
model_config = ConfigDict(from_attributes=True)
user_agent: str | None = None
event_count: int
first_seen: datetime
last_seen: datetime
class AccountSearchEntry(BaseModel):
"""Одна строка `searches` в drilldown — недавний estimate_request."""
model_config = ConfigDict(from_attributes=True)
address: str | None = None
area_m2: str | None = None
rooms: str | None = None
estimate_id: str | None = None
ip_address: str | None = None
created_at: datetime
class AccountActivityEntry(BaseModel):
"""Одна строка `recent_activity` в drilldown — сырое событие."""
model_config = ConfigDict(from_attributes=True)
event_type: str
path: str | None = None
method: str | None = None
ip_address: str | None = None
created_at: datetime
class AccountDrilldown(BaseModel):
"""Полный ответ `GET /audit/accounts/{username}` — 4 списка.
Неизвестный username НЕ 404 это просто пустой отчёт (все списки == []).
"""
ips: list[AccountIpEntry]
devices: list[AccountDeviceEntry]
searches: list[AccountSearchEntry]
recent_activity: list[AccountActivityEntry]
class AnalyticsSummary(BaseModel):
"""Верхнеуровневые счётчики дашборда `GET /analytics`."""
model_config = ConfigDict(from_attributes=True)
total_events: int
distinct_users: int
events_last_24h: int
active_users_last_24h: int
class AnalyticsDailyPoint(BaseModel):
"""Одна точка time-series `daily` — события/пользователи за день."""
model_config = ConfigDict(from_attributes=True)
day: date
events: int
users: int
class AnalyticsTopSearch(BaseModel):
"""Одна строка `top_searches` — самый частый искомый адрес."""
model_config = ConfigDict(from_attributes=True)
address: str | None = None
n: int
class AnalyticsTopPath(BaseModel):
"""Одна строка `top_paths` — самый частый API-путь."""
model_config = ConfigDict(from_attributes=True)
path: str | None = None
n: int
class AnalyticsByAccount(BaseModel):
"""Одна строка `by_account` — сводка активности по username."""
model_config = ConfigDict(from_attributes=True)
username: str
events: int
searches: int
last_seen: datetime
class AnalyticsDashboard(BaseModel):
"""Полный ответ `GET /analytics` — бандл для дашборда Feature 3."""
summary: AnalyticsSummary
daily: list[AnalyticsDailyPoint]
top_searches: list[AnalyticsTopSearch]
top_paths: list[AnalyticsTopPath]
by_account: list[AnalyticsByAccount]

View file

@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel):
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
# HouseAnalyticsKpi.
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
# 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when
# #2141's honest_ratio recompute overwrites the raw ratio_resolver value below).
ratio_basis: str | None = None
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
@ -266,7 +268,7 @@ class AggregatedEstimate(BaseModel):
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
# рекомендация не сработала.
manual_review_recommended: bool = False
manual_review_reasons: list[str] = Field(default_factory=list)
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──

View file

@ -1,8 +1,12 @@
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
Правила:
- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15.
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
default 15.
- Персональный override: таблица account_quota_overrides (username monthly_limit),
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
через negative `used` (ломал /quota «Осталось 50 из 15»).
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) unlimited,
@ -53,11 +57,34 @@ def is_unlimited(username: str) -> bool:
return False
def user_limit(db: Session, username: str) -> int:
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
Источник override таблица account_quota_overrides (см. миграцию
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
negative `used`, который ломал /quota (limit=15, used=-35 remaining=50
«Осталось 50 из 15»).
"""
row = db.execute(
text(
"""
SELECT monthly_limit FROM account_quota_overrides
WHERE username = :u
"""
),
{"u": username},
).fetchone()
if row is not None and row.monthly_limit is not None:
return int(row.monthly_limit)
return MONTHLY_LIMIT
def get_status(db: Session, username: str | None) -> dict:
"""Возвращает статус квоты для пользователя.
Если username is None unlimited True, used 0, remaining 15.
Если unlimited used = фактический или 0, remaining = limit.
Если username is None unlimited True, used 0, remaining = MONTHLY_LIMIT.
Если unlimited used = фактический или 0, remaining = limit (per-user override
или глобальный MONTHLY_LIMIT).
"""
if username is None:
return {
@ -69,6 +96,7 @@ def get_status(db: Session, username: str | None) -> dict:
unlimited = is_unlimited(username)
period = current_period()
limit = user_limit(db, username)
row = db.execute(
text(
@ -84,15 +112,18 @@ def get_status(db: Session, username: str | None) -> dict:
if unlimited:
return {
"limit": MONTHLY_LIMIT,
"limit": limit,
"used": used,
"remaining": MONTHLY_LIMIT,
"remaining": limit,
"unlimited": True,
}
remaining = max(0, MONTHLY_LIMIT - used)
# Защитный кламп: remaining никогда не превышает limit, даже если used всё же
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
# отрицательный used перед вычитанием.
remaining = max(0, limit - max(0, used))
return {
"limit": MONTHLY_LIMIT,
"limit": limit,
"used": used,
"remaining": remaining,
"unlimited": False,
@ -111,6 +142,7 @@ def check_and_raise(db: Session, username: str | None) -> None:
return
period = current_period()
limit = user_limit(db, username)
row = db.execute(
text(
"""
@ -122,13 +154,13 @@ def check_and_raise(db: Session, username: str | None) -> None:
).fetchone()
used = row.used if row is not None else 0
if used >= MONTHLY_LIMIT:
if used >= limit:
logger.warning(
"quota exhausted: username=%r period=%s used=%d limit=%d",
username,
period,
used,
MONTHLY_LIMIT,
limit,
)
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
@ -140,14 +172,16 @@ def increment(db: Session, username: str | None) -> bool:
None / unlimited True (no-op success).
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE
два параллельных запроса при used=MONTHLY_LIMIT-1 не могут оба инкрементировать
(второй упрётся в WHERE RETURNING пуст False). Свежая вставка (used=1) НЕ
задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит.
два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
упрётся в WHERE RETURNING пуст False). Свежая вставка (used=1) НЕ задевается
WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim`
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
"""
if username is None or is_unlimited(username):
return True
period = current_period()
lim = user_limit(db, username)
row = db.execute(
text(
"""
@ -161,7 +195,7 @@ def increment(db: Session, username: str | None) -> bool:
RETURNING used
"""
),
{"u": username, "p": period, "lim": MONTHLY_LIMIT},
{"u": username, "p": period, "lim": lim},
).fetchone()
db.commit()
ok = row is not None
@ -172,6 +206,6 @@ def increment(db: Session, username: str | None) -> bool:
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
username,
period,
MONTHLY_LIMIT,
lim,
)
return ok

View file

@ -28,7 +28,7 @@ class Brand:
_DEFAULT = Brand(
slug="generic",
name="Trade-In Estimator",
name="МЕРА",
logo_url=None,
primary_color="#1d4ed8",
accent_color="#f59e0b",

View file

@ -167,10 +167,27 @@ async def clean_address(address: str) -> DadataAddressResult | None:
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
return None
if status in (401, 403):
logger.error(
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
status,
)
body_preview = (response.text or "")[:200]
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
if status == 403 and (
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
):
logger.error(
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
body_preview,
)
else:
logger.error(
"dadata: HTTP %d — auth/secret rejected. "
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
status,
body_preview,
)
return None
if status >= 500:
logger.warning("dadata: HTTP %d — transient server error", status)
@ -246,6 +263,12 @@ class DadataSuggestion:
street: str | None
city: str | None
kind: str
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
house_fias_id: str | None = None
kladr_id: str | None = None
qc_geo: int | None = None
def _classify_kind(fias_level: Any) -> str:
@ -295,11 +318,17 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
street=data.get("street"),
city=data.get("city"),
kind=_classify_kind(data.get("fias_level")),
house_fias_id=data.get("house_fias_id"),
kladr_id=data.get("kladr_id"),
qc_geo=_coerce_int(data.get("qc_geo")),
)
async def suggest_addresses(
query: str, limit: int = 8, city: str = "Екатеринбург"
query: str,
limit: int = 8,
city: str | None = "Екатеринбург",
region: str | None = None,
) -> list[DadataSuggestion]:
"""Автокомплит адресов через DaData /suggest/address.
@ -307,6 +336,13 @@ async def suggest_addresses(
query: текст для подсказки (минимум 2 символа).
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
Игнорируется, если задан `region`.
region: область для hard-constraint в `locations` используется вместо
`city`, когда нужен охват шире одного города (например, вся
Свердловская область / region 66). DaData `locations` сам по себе уже
жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется.
Returns:
list[DadataSuggestion] пустой список если:
@ -334,7 +370,13 @@ async def suggest_addresses(
"query": query.strip(),
"count": max(1, min(int(limit), 20)),
}
if city:
if region:
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
# поэтому не добавляем.
body["locations"] = [{"region": region}]
elif city:
body["locations"] = [{"city": city}]
try:
@ -352,9 +394,7 @@ async def suggest_addresses(
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
return []
if status in (401, 403):
logger.error(
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
)
logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
return []
if status >= 500:
logger.warning("dadata suggest: HTTP %d — transient server error", status)
@ -387,3 +427,55 @@ async def suggest_addresses(
logger.info("dadata suggest: %r%d вариантов", query[:60], len(out))
return out
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
async def enrich_address(address: str) -> DadataAddressResult | None:
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
Порядок:
1. /clean/address (token+secret) canonical + house_cadnum + house_fias_id +
qc_geo + metro. Если вернул результат используем его.
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день)
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) они остаются
None. Честная деградация: недостающие поля не выдумываем.
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id
ключ join с houses/ДОМ.РФ (year_built и пр.) терялся, хотя /suggest его отдаёт
бесплатно. Город НЕ ограничиваем (city="") enrichment работает по всей обл. (66).
"""
cleaned = await clean_address(address)
if cleaned is not None:
return cleaned
suggestions = await suggest_addresses(address, limit=5, city="")
for s in suggestions:
fias = s.house_fias_id or s.fias_id
if s.kind == "house" and fias:
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
qc_geo = s.qc_geo if s.qc_geo is not None else 0
logger.info(
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
"(canonical/cadnum недоступны без CLEAN)",
address[:60],
fias,
)
return DadataAddressResult(
canonical_address=None,
house_cadnum=None,
house_fias_id=fias,
lat=s.lat,
lon=s.lon,
qc_geo=qc_geo,
qc_house=None,
kladr_id=s.kladr_id,
okato=None,
oktmo=None,
metro=[],
raw={"_source": "suggest_fallback", "value": s.value},
)
return None

View file

@ -0,0 +1,578 @@
"""ДОМ.РФ капремонт open data loader: houses.year_built/material_walls (issue #2013).
CONTEXT: houses.year_built заполнен только на 38%, houses.material_walls на 0%.
Существующий ГИС-ЖКХ loader (zhkh_flats_loader.py, мигр. 146/149) заполняет
houses.zhkh_year (70%) и houses.zhkh_floors (70%), НО никогда не копирует их в
houses.year_built/total_floors это основной пробел. material_walls вообще ни разу
не заполнялся никаким источником. Estimator (app/services/estimator.py) фильтрует
когорту по `year_built BETWEEN ...` реальные годы напрямую двигают точность оценки.
ИСТОЧНИК: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
КР1.1 house registry (export/190) zip CSV, delimiter ';', UTF-8 BOM. Колонки:
mkd_code, houseguid (ФИАС GUID дома), address, commission_year (год ввода, int),
total_sq (decimal, запятая «909,80»), number_floors_max (int), + служебные.
КР1.2 constructive elements (export/275) zip CSV, тот же delimiter/encoding.
LONG FORMAT: одна строка на конструктивный элемент на mkd_code; wall_material
заполнен ТОЛЬКО на строке construction_element_type='фасад'. Чтобы получить
материал стен на дом группируем по mkd_code, берём первую строку с непустым
wall_material.
TLS: домен домрф.рф отдаёт RU-сертификат, который httpx с default trust store не
верифицирует ("unable to get local issuer certificate") та же ситуация, что и у
sber_index.py. Открытые неавторизованные данные, поэтому используем verify=False,
как остальные RU-gov loader'ы в этом репо.
Матч staginghouses: domrf_kapremont.houseguid = COALESCE(houses.gar_house_guid,
houses.house_fias_id, houses.zhkh_house_guid) простой приоритетный COALESCE-джойн
(без multi-attempt fallback-если-нет-матча see PR discussion, "keep it simple/safe").
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""
from __future__ import annotations
import csv
import io
import logging
import tempfile
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Константы
# ─────────────────────────────────────────────────────────────────────────────
# export/190 (КР1.1) и export/275 (КР1.2) без доп. параметров отдают Свердловскую
# обл. (region 66) — подтверждено инспекцией скачанных файлов. Для других регионов
# понадобится region-gid параметр (не реализовано — вне скоупа #2013).
KR11_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/190"
KR12_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/275"
DOWNLOAD_TIMEOUT_SEC = 180
# UPSERT в staging чанками (в одном SAVEPOINT), как zhkh_flats_loader.SAVEPOINT_CHUNK —
# сбойный чанк откатывается изолированно, остальные доезжают.
UPSERT_CHUNK_SIZE = 500
# Правдоподобные границы года постройки. Значение вне диапазона — почти наверняка
# опечатка/мусор: для МКД-реестра капремонта дом < 1850 нереален, а > текущий+2 ещё
# физически не введён в эксплуатацию. Такой год НЕ должен (а) попадать из ДОМ.РФ CSV
# в staging (parse-гейт в parse_int_field), (б) экранировать авторитетный ДОМ.РФ
# commission_year при бэкфилле houses.year_built (SQL-гейт в _BACKFILL_*_SQL).
YEAR_BUILT_MIN = 1850
YEAR_BUILT_FUTURE_SLACK = 2 # дом «в стройке» — текущий год + 2 ещё допустим
def plausible_year_max() -> int:
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
return date.today().year + YEAR_BUILT_FUTURE_SLACK
# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
def parse_decimal_comma(raw: str | None) -> float | None:
"""«909,80» / «909.80» / «» / None → float|None. Запятая — decimal separator ДОМ.РФ CSV."""
if raw is None:
return None
s = raw.strip()
if not s:
return None
try:
return float(s.replace(",", "."))
except ValueError:
return None
def parse_int_field(
raw: str | None,
*,
min_value: int | None = None,
max_value: int | None = None,
) -> int | None:
"""Устойчивый str → int|None (commission_year / number_floors_max).
Пусто/None/нечисло None. Терпит decimal-строки («5,0») через float-фоллбек
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
min_value/max_value опциональный sanity-гейт: распарсенное значение вне
[min_value, max_value] трактуется как None (для commission_year передаём
YEAR_BUILT_MIN..plausible_year_max(), чтобы мусорный год не попал в staging;
для number_floors_max границы не задаём там гейт не требуется).
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
value: int | None = None
try:
value = int(s)
except ValueError:
try:
value = int(float(s.replace(",", ".")))
except ValueError:
return None
if min_value is not None and value < min_value:
return None
if max_value is not None and value > max_value:
return None
return value
@dataclass(slots=True)
class Kr11Row:
"""Одна строка КР1.1 house registry (house-per-row)."""
mkd_code: str
houseguid: str | None
address: str | None
commission_year: int | None
number_floors_max: int | None
total_sq: float | None
@dataclass(slots=True)
class DomrfHouse:
"""Объединённая КР1.1 + КР1.2(wall_material) строка — готова к UPSERT в staging."""
mkd_code: str
houseguid: str | None
address: str | None
commission_year: int | None
number_floors_max: int | None
total_sq: float | None
wall_material: str | None
def _open_csv(path: str | Path) -> Iterator[dict[str, str]]:
"""csv.DictReader по ДОМ.РФ CSV: delimiter ';', UTF-8 BOM (utf-8-sig)."""
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f, delimiter=";")
yield from reader
def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]:
"""КР1.1 → dict по mkd_code. Строки без mkd_code пропускаются.
Дубликат mkd_code (не ожидается mkd_code PK в реестре ДОМ.РФ) последняя
строка побеждает (совпадает с семантикой ON CONFLICT DO UPDATE ниже).
"""
rows: dict[str, Kr11Row] = {}
for raw in _open_csv(path):
mkd_code = (raw.get("mkd_code") or "").strip()
if not mkd_code:
continue
rows[mkd_code] = Kr11Row(
mkd_code=mkd_code,
houseguid=(raw.get("houseguid") or "").strip() or None,
address=(raw.get("address") or "").strip() or None,
# sanity-гейт: год вне [1850, текущий+2] — мусор, не пускаем в staging
# (иначе он позже мог бы записаться в houses.year_built как «авторитетный»).
commission_year=parse_int_field(
raw.get("commission_year"),
min_value=YEAR_BUILT_MIN,
max_value=plausible_year_max(),
),
number_floors_max=parse_int_field(raw.get("number_floors_max")),
total_sq=parse_decimal_comma(raw.get("total_sq")),
)
return rows
def parse_kr12_wall_materials(path: str | Path) -> dict[str, str]:
"""КР1.2 (long-format) → dict mkd_code → wall_material.
wall_material непустой ТОЛЬКО на строке construction_element_type='фасад'
группировка не по этому полю, а просто "первая непустая wall_material на
mkd_code" (устойчиво даже если разметка типа элемента когда-то изменится).
"""
materials: dict[str, str] = {}
for raw in _open_csv(path):
mkd_code = (raw.get("mkd_code") or "").strip()
wall = (raw.get("wall_material") or "").strip()
if mkd_code and wall and mkd_code not in materials:
materials[mkd_code] = wall
return materials
def build_domrf_houses(
kr11: dict[str, Kr11Row], wall_materials: dict[str, str]
) -> list[DomrfHouse]:
"""КР1.1 rows + КР1.2 wall_material lookup → список DomrfHouse (для UPSERT)."""
return [
DomrfHouse(
mkd_code=row.mkd_code,
houseguid=row.houseguid,
address=row.address,
commission_year=row.commission_year,
number_floors_max=row.number_floors_max,
total_sq=row.total_sq,
wall_material=wall_materials.get(row.mkd_code),
)
for row in kr11.values()
]
# ─────────────────────────────────────────────────────────────────────────────
# HTTP: скачивание zip + извлечение CSV
# ─────────────────────────────────────────────────────────────────────────────
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
resp.raise_for_status()
return resp.content
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
with zipfile.ZipFile(io.BytesIO(data)) as zf:
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
if not csv_names:
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
extracted = zf.extract(csv_names[0], dest_dir)
return Path(extracted)
def fetch_domrf_csvs(dest_dir: Path, *, client: httpx.Client) -> tuple[Path, Path]:
"""Скачивает КР1.1 + КР1.2 zip'ы, распаковывает в dest_dir. Возвращает (kr11, kr12)."""
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest_dir)
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest_dir)
return kr11_path, kr12_path
# ─────────────────────────────────────────────────────────────────────────────
# UPSERT staging (domrf_kapremont, мигр. 176)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = text(
"""
INSERT INTO domrf_kapremont (
mkd_code, houseguid, address, commission_year,
number_floors_max, total_sq, wall_material, loaded_at
)
VALUES (
CAST(:mkd_code AS text), CAST(:houseguid AS text), CAST(:address AS text),
CAST(:commission_year AS smallint), CAST(:number_floors_max AS int),
CAST(:total_sq AS numeric), CAST(:wall_material AS text), now()
)
ON CONFLICT (mkd_code) DO UPDATE SET
houseguid = EXCLUDED.houseguid,
address = EXCLUDED.address,
commission_year = EXCLUDED.commission_year,
number_floors_max = EXCLUDED.number_floors_max,
total_sq = EXCLUDED.total_sq,
wall_material = EXCLUDED.wall_material,
loaded_at = now()
WHERE domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid
OR domrf_kapremont.address IS DISTINCT FROM EXCLUDED.address
OR domrf_kapremont.commission_year IS DISTINCT FROM EXCLUDED.commission_year
OR domrf_kapremont.number_floors_max IS DISTINCT FROM EXCLUDED.number_floors_max
OR domrf_kapremont.total_sq IS DISTINCT FROM EXCLUDED.total_sq
OR domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material
"""
)
def _chunk_houses(items: list[DomrfHouse], size: int) -> Iterator[list[DomrfHouse]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def upsert_domrf_kapremont(
db: Session, houses: list[DomrfHouse], *, chunk_size: int = UPSERT_CHUNK_SIZE
) -> int:
"""UPSERT списка DomrfHouse в domrf_kapremont, чанками по SAVEPOINT. Не коммитит (caller).
Идемпотентно (IS DISTINCT FROM gate в _UPSERT_SQL повторный прогон с теми же
данными не трогает уже актуальные строки). Сбойный чанк откатывается изолированно
(та же схема, что zhkh_flats_loader.match_houses_to_zhkh).
"""
upserted = 0
for chunk in _chunk_houses(houses, chunk_size):
try:
with db.begin_nested():
for h in chunk:
res = db.execute(
_UPSERT_SQL,
{
"mkd_code": h.mkd_code,
"houseguid": h.houseguid,
"address": h.address,
"commission_year": h.commission_year,
"number_floors_max": h.number_floors_max,
"total_sq": h.total_sq,
"wall_material": h.wall_material,
},
)
upserted += res.rowcount
except Exception:
logger.warning(
"domrf_kapremont upsert: чанк из %d строк сбойнул (откат savepoint)",
len(chunk),
exc_info=True,
)
return upserted
def load_domrf_kapremont(
db: Session,
*,
kr11_path: str | Path | None = None,
kr12_path: str | Path | None = None,
work_dir: str | Path | None = None,
chunk_size: int = UPSERT_CHUNK_SIZE,
dry_run: bool = False,
) -> dict[str, int]:
"""Скачивает (если пути не заданы) КР1.1+КР1.2, парсит, UPSERT в domrf_kapremont.
kr11_path/kr12_path локальные CSV (пропустить скачивание; используется тестами
и ops-дебагом). work_dir куда распаковывать скачанные zip (по умолчанию
временный каталог, удаляется после загрузки). dry_run парс происходит, но НИ
ОДНОЙ записи в БД не делается. Не коммитит (caller).
"""
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
if kr11_path is None or kr12_path is None:
if work_dir is not None:
dest = Path(work_dir)
dest.mkdir(parents=True, exist_ok=True)
else:
tmp_ctx = tempfile.TemporaryDirectory()
dest = Path(tmp_ctx.name)
# verify=False: домрф.рф отдаёт RU-сертификат, не верифицируемый дефолтным
# trust store'ом (та же ситуация, что sber_index.py). Публичные open data,
# без auth/PII — приемлемо skip'нуть TLS-верификацию.
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
if kr11_path is None:
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest)
if kr12_path is None:
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest)
try:
kr11 = parse_kr11_csv(kr11_path)
wall_materials = parse_kr12_wall_materials(kr12_path)
houses = build_domrf_houses(kr11, wall_materials)
upserted = 0 if dry_run else upsert_domrf_kapremont(db, houses, chunk_size=chunk_size)
finally:
if tmp_ctx is not None:
tmp_ctx.cleanup()
result = {
"kr11_rows": len(kr11),
"kr12_wall_rows": len(wall_materials),
"houses_built": len(houses),
"upserted": upserted,
}
logger.info("domrf_kapremont load DONE (dry_run=%s): %s", dry_run, result)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Backfill houses.year_built/material_walls/total_floors
# ─────────────────────────────────────────────────────────────────────────────
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком (COALESCE, как в issue #2013).
#
# ПРИОРИТЕТ year_built (Fix A): ВАЛИДНОЕ существующее значение выигрывает (COALESCE
# семантика сохранена для valid). НО невозможное существующее значение (< :ymin,
# > :ymax или NULL) НЕ должно экранировать авторитетный ДОМ.РФ commission_year —
# для домов реестра капремонта commission_year это источник истины по году ввода.
# Поэтому impossible/NULL заменяется валидным commission_year → затем валидным
# zhkh_year; если валидной замены нет — оставляем существующее как есть (не обнуляем
# даже мусор, чтобы не терять данные без выигрыша). Кандидаты замены тоже проходят
# плаузибилити-гейт [:ymin, :ymax], чтобы в houses.year_built не попал мусорный год.
# material_walls/total_floors — прежняя COALESCE-семантика (только заполнить NULL).
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
"""
UPDATE houses h
SET year_built = CASE
WHEN h.year_built BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.year_built
ELSE COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END,
h.year_built
)
END,
material_walls = COALESCE(h.material_walls, d.wall_material),
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
FROM domrf_kapremont d
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
AND (
(
(h.year_built IS NULL
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
AND COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END
) IS NOT NULL
)
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
)
"""
)
_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text(
"""
SELECT count(*)
FROM houses h
JOIN domrf_kapremont d
ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
WHERE (
(h.year_built IS NULL
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
AND COALESCE(
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN d.commission_year END,
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
THEN h.zhkh_year END
) IS NOT NULL
)
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
"""
)
# Шаг 2: ЛЮБОЙ дом (включая без domrf-матча вообще — шаг 1 INNER JOIN их не трогает)
# добивается zhkh_year/zhkh_floors, если ещё NULL. Домам, уже тронутым шагом 1, шаг 2
# ничего не меняет: их COALESCE в шаге 1 уже включал zhkh_* как fallback, поэтому
# гейт `IS NULL AND zhkh_* IS NOT NULL` здесь для них ложен — шаги взаимоисключающи
# (не двойной счёт при суммировании houses_updated).
_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL = text(
"""
UPDATE houses
SET year_built = COALESCE(year_built, zhkh_year),
total_floors = COALESCE(total_floors, zhkh_floors)
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
"""
)
_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL = text(
"""
SELECT count(*)
FROM houses
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
"""
)
def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[str, int]:
"""COALESCE(year_built, domrf.commission_year, zhkh_year) + material_walls + total_floors.
Двухшаговый UPDATE (мирроринг zhkh guid-match cadastre-fallback паттерна из
zhkh_flats_loader.py):
1) дома с domrf-матчем (COALESCE(gar_house_guid, house_fias_id, zhkh_house_guid) =
domrf_kapremont.houseguid) заполняются из domrf, с zhkh_year/zhkh_floors
фоллбеком внутри той же COALESCE.
2) ЛЮБОЙ дом (в т.ч. без domrf-матча) добивает year_built/total_floors из
zhkh_year/zhkh_floors, если шаг 1 их не тронул.
Только COALESCE (заполняет NULL, никогда не перезаписывает existing non-null).
Не коммитит (caller). dry_run ноль записей, только SELECT count(*) по тем же
предикатам.
"""
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
if dry_run:
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL, year_bounds).scalar_one()
zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one()
result = {
"domrf_matched": domrf_matched,
"zhkh_fallback": zhkh_fallback,
"houses_updated": 0,
}
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
return result
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL, year_bounds).rowcount
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
result = {
"domrf_matched": domrf_updated,
"zhkh_fallback": zhkh_updated,
"houses_updated": domrf_updated + zhkh_updated,
}
logger.info("backfill_houses_from_domrf DONE: %s", result)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Propagate houses.year_built → listings.year_built
# ─────────────────────────────────────────────────────────────────────────────
# Только NULL listings.year_built, только когда houses.year_built уже известен —
# никогда не перезаписывает существующий listings.year_built (source-provided данные
# приоритетнее houses-агрегата).
#
# LINK-CONSISTENCY GUARD (Fix B): раньше пропагация шла по house_id_fk БЕЗ проверки,
# что listing реально относится к этому дому. Перепривязанный FK (house dedup/merge
# «съезжает» на дом за сотни метров) впрыскивал бы чужой когортный год в живой comp.
# Пропагируем только если координаты самого объявления в пределах
# PROPAGATE_MAX_DISTANCE_M от геометрии дома. Если у объявления нет координат —
# консервативный address-фоллбек: адрес объявления должен содержать short_address
# дома (тот же дом по адресной строке); нет short_address — не пропагируем.
PROPAGATE_MAX_DISTANCE_M = 500
# Общий link-consistency предикат для UPDATE и COUNT (единый источник — одна правка
# порога PROPAGATE_MAX_DISTANCE_M меняет обе SQL). Порог — int-константа, не bind-param
# (в ST_DWithin проще литерал; значение доверенное, не из пользовательского ввода).
_PROPAGATE_LINK_GUARD = f"""
AND (
(l.geom IS NOT NULL AND h.geom IS NOT NULL
AND ST_DWithin(
CAST(l.geom AS geography), CAST(h.geom AS geography),
{PROPAGATE_MAX_DISTANCE_M}))
OR (l.geom IS NULL
AND h.short_address IS NOT NULL AND l.address IS NOT NULL
AND l.address ILIKE '%' || h.short_address || '%')
)
"""
_PROPAGATE_LISTINGS_YEAR_SQL = text(
f"""
UPDATE listings l
SET year_built = h.year_built
FROM houses h
WHERE l.house_id_fk = h.id
AND l.year_built IS NULL
AND h.year_built IS NOT NULL
{_PROPAGATE_LINK_GUARD}
"""
)
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
f"""
SELECT count(*)
FROM listings l
JOIN houses h ON l.house_id_fk = h.id
WHERE l.year_built IS NULL
AND h.year_built IS NOT NULL
{_PROPAGATE_LINK_GUARD}
"""
)
def propagate_listings_year_from_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
"""UPDATE listings.year_built = houses.year_built где listing.year_built ещё NULL.
Не коммитит (caller). dry_run ноль записей, только SELECT count(*) по тому же
предикату (ключ результата would_update вместо listings_updated).
"""
if dry_run:
would_update = db.execute(_PROPAGATE_LISTINGS_YEAR_COUNT_SQL).scalar_one()
logger.info("propagate_listings_year_from_houses DRY-RUN: would_update=%d", would_update)
return {"listings_updated": 0, "would_update": would_update}
updated = db.execute(_PROPAGATE_LISTINGS_YEAR_SQL).rowcount
logger.info("propagate_listings_year_from_houses DONE: listings_updated=%d", updated)
return {"listings_updated": updated}

File diff suppressed because it is too large Load diff

View file

@ -40,7 +40,6 @@ import logging
import pathlib
import re
from urllib.parse import urlparse
from uuid import UUID
import matplotlib
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
return f"<span class='mono'>{text}</span>"
def _report_number(estimate_id: UUID) -> str:
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
short = int(estimate_id.int) % 10_000
long = int(estimate_id.int) % 10_000_000_000
return f"EKБ-{short:04d}-{long:010d}"
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
_CITY_REPORT_PREFIX: dict[str, str] = {
"екатеринбург": "ЕКБ",
"нижний тагил": "НТ",
"каменск-уральский": "КУ",
"первоуральск": "ПРВ",
"серов": "СЕР",
"новоуральск": "НВУ",
"ревда": "РЕВ",
"полевской": "ПЛВ",
"асбест": "АСБ",
"верхняя пышма": "ВП",
"березовский": "БРЗ",
"краснотурьинск": "КРТ",
"камышлов": "КМШ",
}
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
_DEFAULT_REPORT_PREFIX = "МЕРА"
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
_REPORT_CITY_RE = re.compile(
r"\b(?:"
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
+ r")\b"
)
def _report_city_prefix(address: str | None) -> str:
"""Буквенный префикс № отчёта по городу из адреса (region 66).
«МЕРА» если город не распознан не выдумываем локацию (раньше был хардкод
«EKБ» для любого адреса, включая не-екатеринбургские). ёе нормализация до
поиска (симметрично _resolve_target_city в estimator.py)."""
if not address:
return _DEFAULT_REPORT_PREFIX
norm = address.replace("ё", "е").replace("Ё", "е").lower()
m = _REPORT_CITY_RE.search(norm)
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
def _report_number(estimate: AggregatedEstimate) -> str:
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
PREFIX буквенный код города оценки (ЕКБ / НТ / по target_address, «МЕРА»
если город не распознан); NNNN/XXXXXXXXXX детерминированные коды от
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
short = int(estimate.estimate_id.int) % 10_000
long = int(estimate.estimate_id.int) % 10_000_000_000
return f"{prefix}-{short:04d}-{long:010d}"
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
@ -518,7 +566,7 @@ def _page_footer(
# тела страницы) и был источником сложности; заменён на простую тонкую
# градиентную линию-разделитель между строками meta/wordmark.
return f"""
<div style="margin-top:20pt;">
<div style="margin-top:6pt;">
{note_html}
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
@ -811,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
)
explainer = (
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
"Росреестра, обычно ниже запроса."
@ -821,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
return (
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
f"{explainer}"
)
@ -970,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
cv_str = f"{cv}%" if cv is not None else ""
gauge = _data_quality_gauge_svg(estimate.confidence)
return f"""
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt;
<div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
{gauge}
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
@ -1000,7 +1048,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
if estimate.expires_at is not None
else today + dt.timedelta(days=30)
)
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
# Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "")
@ -1038,30 +1086,38 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
house_label = house_labels.get(house_type, "") if house_type else ""
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else ""
# #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
# таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
year_label = _mono(str(year_built)) if year_built else ""
# Active market subband — 4-118 days range (fallback без данных).
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
days_min, days_max = _days_on_market_range(estimate.analogs)
# Deals range — если deals есть, считаем; иначе fallback к listings range
deals_low, deals_high = _deals_range(
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
)
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
listings_bar = _price_range_chart_svg(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Активный рынок с аналогичным состоянием ремонта",
days_min=days_min,
days_max=days_max,
show_days=True,
days_min=days_range[0] if days_range else None,
days_max=days_range[1] if days_range else None,
show_days=days_range is not None,
)
deals_bar = _price_range_chart_svg(
deals_low,
deals_high,
sub_label="Диапазон цен по фактическим сделкам",
)
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
# рассчитанный дисконт запрос→продажа (тот же, что chip «N%» в dual-price),
# а не хардкод «1018%», который противоречил бы вычисленному «N%» (#pdf-honesty).
discount_pct = _discount_pct(estimate)
if discount_pct is not None:
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"ДомКлик и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, ДомКлик и продажи агентств недвижимости"
)
disclaimer_html = ""
if brand.pdf_disclaimer:
@ -1070,8 +1126,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
f"{_html.escape(brand.pdf_disclaimer)}</p>"
)
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
heading_html = _section_heading(
"01",
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
@ -1082,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
return f"""
<div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html}
<!-- Параметры квартиры (dotted-row ObjectSummary.tsx summary-row идиома) -->
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
<tr><td class="dotted-row"> отчета</td>
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
<tr><td class="dotted-row">Дата отчета</td>
@ -1096,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
<tr><td class="dotted-row">Год постройки</td>
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr>
<td class="bold dotted-row">{year_label}</td></tr>
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
<tr><td class="dotted-row">Этаж / этажность</td>
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
@ -1116,58 +1168,56 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
{_dual_price_block(estimate, brand)}
<div style="break-inside:avoid;page-break-inside:avoid;">
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
<p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен в объявлениях
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
</p>
{listings_bar}
</div>
<div style="break-inside:avoid;page-break-inside:avoid;">
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
Диапазон цен по фактическим сделкам
</p>
{deals_bar}
</div>
<!-- Что важно при оценке -->
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
<h3 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
Что важно при оценке стоимости квартиры:
</h3>
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
<tr>
<td class="advice-title">Цены в объявлениях ожидания собственников</td>
<td class="advice-text">Фактические сделки проходят на 1018% ниже, что подтверждают
Росреестр, ДомКлик и продажи агентств недвижимости</td>
<td class="advice-text">{advice_discount_text}</td>
</tr>
<tr>
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
<td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего
на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td>
</tr>
<tr>
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
</tr>
</table>
<!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
самостоятельной продаже до 15%») убрана дублирует детальную
постатейную разбивку на Offer-странице (см. _build_offer_page: торг
5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
сохранён полностью. -->
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
padding-top:4pt;overflow-wrap:anywhere;">
<p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
padding-top:3pt;overflow-wrap:anywhere;">
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
</p>
{disclaimer_html}
{footer_html}
</div>
"""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market обложка и
страница объявлений печатали этот выдуманный коридор как измеренный срок
экспозиции (~48% оценок без days_on_market). Теперь None caller не рисует
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
if not days:
return 4, 118
return None
return min(days), max(days)
@ -1225,38 +1275,27 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
)
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
# Полоска диапазона
days_min, days_max = _days_on_market_range(estimate.analogs)
# Полоска диапазона — срок экспозиции только при реальных days_on_market
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
range_bar = _price_range_chart_svg(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Рынок",
days_min=days_min,
days_max=days_max,
show_days=True,
days_min=days_range[0] if days_range else None,
days_max=days_range[1] if days_range else None,
show_days=days_range is not None,
)
# Топ-5 примеров (отсортированных по distance)
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5)
report_num = _report_number(estimate.estimate_id)
today = dt.date.today()
footer_html = _page_footer(
brand,
report_num,
today,
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
valid_until=_expires_date(estimate),
)
heading_html = _section_heading("02", "РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
return f"""
<div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html}
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
@ -1306,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
<tbody>{examples_rows}</tbody>
</table>
{footer_html}
</div>
"""
@ -1391,6 +1428,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
# который противоречил бы вычисленному «N%» в том же PDF. Убран и ложный
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
discount_pct = _discount_pct(estimate)
if discount_pct is not None:
deals_discount_text = (
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
f"объявлениях — по этому объекту на {discount_pct}%"
)
else:
deals_discount_text = (
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
@ -1438,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5)
report_num = _report_number(estimate.estimate_id)
footer_html = _page_footer(
brand,
report_num,
today,
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
valid_until=_expires_date(estimate),
)
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
return f"""
<div style="page-break-after:always;">
{_page_header(brand, report_num, today)}
{heading_html}
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
@ -1495,8 +1536,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
По данным реальных сделок, квартиры продаются в среднем на 1018% дешевле,
чем заявлено в объявлениях (Екатеринбург, 2026)
{deals_discount_text}
</div>
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
@ -1508,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<tbody>{examples_rows}</tbody>
</table>
{footer_html}
</div>
"""
@ -1517,7 +1555,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
median = estimate.median_price_rub
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
@ -1544,8 +1582,24 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
rieltor_low = int(sold_price * rieltor_pct_low / 100)
rieltor_high = int(sold_price * rieltor_pct_high / 100)
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
rent_low, rent_high = 84_000, 135_000
# 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
# тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
# был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
# Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
# студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
# Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
rooms = int(input_snapshot.get("rooms", 0) or 0)
if rooms <= 1:
rent_multiplier = 1.0
elif rooms == 2:
rent_multiplier = 1.35
elif rooms == 3:
rent_multiplier = 1.8
else:
rent_multiplier = 2.4
rent_base_low, rent_base_high = 84_000, 135_000
rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
juridical_low, juridical_high = 15_000, 50_000
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
@ -1558,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
report_num = _report_number(estimate.estimate_id)
today = dt.date.today()
footer_html = _page_footer(
brand,
report_num,
today,
f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
"Расчёт носит ориентировочный характер и не является офертой.",
valid_until=_expires_date(estimate),
)
return f"""
<div><!-- last page: NO page-break-after -->
{_page_header(brand, report_num, today)}
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
@ -1626,7 +1667,8 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
<tr style="border-bottom:1px solid {_BORDER};">
<td style="padding:6pt 4pt;">
<div class="bold">Аренда после сделки</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">однокомнатной квартиры на 3 месяца</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td>
@ -1738,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
</div>
</div>
{footer_html}
<p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
Расчёт носит ориентировочный характер и не является офертой.
</p>
</div>
"""
@ -1794,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
}}
@page {{
size: A4;
margin: 20mm 18mm 20mm 18mm;
margin: 25mm 18mm 19mm 18mm;
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
}}
.running-header {{ position: running(runningHeader); }}
.running-footer {{ position: running(runningFooter); }}
* {{ box-sizing: border-box; }}
body {{
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
@ -2015,7 +2063,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
"""
address = _html.escape(input_snapshot.get("address", ""))
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
today = dt.date.today()
return f"""
<div>
@ -2103,11 +2151,28 @@ def generate_trade_in_pdf(
if estimate.insufficient_data:
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
else:
# Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
# margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
# документа, а не только там, где явно вставлен в поток секции-билдера.
# Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
# печатает независимый running-элемент с последнего "видимого" перед разрывом
# страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
_chrome_report_num = _report_number(estimate)
_chrome_today = dt.date.today()
_chrome_expires = _expires_date(estimate)
chrome_html = (
f'<div class="running-header">'
f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
f'<div class="running-footer">'
f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
f"</div>"
)
body_html = (
_build_cover(estimate, input_snapshot, brand)
chrome_html
+ _build_cover(estimate, input_snapshot, brand)
+ _build_listings_page(estimate, input_snapshot, brand)
+ _build_deals_page(estimate, input_snapshot, brand)
+ _build_offer_page(estimate, brand)
+ _build_offer_page(estimate, input_snapshot, brand)
)
html_str = (

View file

@ -72,6 +72,165 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
# bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
OBLAST66_VIEWBOX = {
"viewbox": "56.7,62.2,66.6,55.8",
"ll": "61.65,59.0",
"spn": "9.9,6.4",
}
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
"""True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
# городов), покрывает основные региональные центры. Используется чтобы:
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
# другой город региона (геокодер forward/suggest prefix-логика);
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
SVERDLOVSK_OBLAST_CITIES = frozenset(
{
"екатеринбург",
"нижний тагил",
"каменск-уральский",
"первоуральск",
"серов",
"новоуральск",
"ревда",
"полевской",
"асбест",
"богданович",
"ирбит",
"красноуфимск",
"верхняя пышма",
"берёзовский",
"березовский",
"заречный",
"качканар",
"краснотурьинск",
"североуральск",
"ивдель",
"тавда",
"туринск",
"сысерть",
"среднеуральск",
"дегтярск",
"верхняя салда",
"нижняя салда",
"невьянск",
"артёмовский",
"артемовский",
"камышлов",
"алапаевск",
"сухой лог",
"кушва",
"красноуральск",
"карпинск",
"нижняя тура",
"верхний тагил",
"нижние серги",
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
# «серов» идёт «а» — тоже \w, границы нет).
_OBLAST_CITY_RE = re.compile(
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
)
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р", "р-он", "район", "жк"})
def _has_oblast_marker(text_lower: str) -> bool:
"""True если текст уже содержит упоминание области/города региона 66.
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
уже привязан к другому городу/области иначе получим двойной город
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
Матчинг по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
"""
normalized = " ".join(text_lower.split())
if _OBLAST_MARKER_RE.search(normalized):
return True
for m in _OBLAST_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
return True
return False
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
_NON_EKB_CITY_RE = re.compile(
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
)
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
def _names_non_ekb_city(address: str) -> bool:
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
города региона иначе ЖК/посёлки с названием, совпадающим с другим
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
"""
normalized = " ".join(address.lower().split())
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
if _EKATERINBURG_RE.search(normalized):
return False
for m in _NON_EKB_CITY_RE.finditer(normalized):
prefix_words = normalized[: m.start()].split()
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
return True
return False
# ── Address normalisation ───────────────────────────────────────────────────
def normalize_address(address: str) -> str:
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
@ -166,11 +325,35 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
"""Single Nominatim search. Возвращает первый item или None.
def _nominatim_region_ok(item: dict) -> bool | None:
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
не возвращать Пермский край / Челябинск.
True/False если state однозначно про/не про Свердловскую область. None если
поле отсутствует/не строка тогда accept-логика падает обратно на bbox.
Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри генерального OBLAST66_BBOX
(специально щедрого), но их state явно другой регион.
"""
addr = item.get("address")
if not isinstance(addr, dict):
return None
state = addr.get("state")
if not isinstance(state, str) or not state:
return None
return "свердловск" in state.lower()
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
"""Single Nominatim search. Возвращает лучший item или None.
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
опечатках не возвращать Пермский край / Челябинск но не резать легитимные
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
не первым) иначе первый кандидат внутри OBLAST66. Плюс region cross-check
(`address.state`) отсекает кандидатов ЯВНО из другого региона (Тюмень и
т.п.), даже если координаты попали в генеральный bbox.
"""
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -180,21 +363,26 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
"limit": "3",
"countrycodes": "ru",
"addressdetails": "1",
"viewbox": EKB_BBOX["viewbox"],
"bounded": "1", # строго в ЕКБ bbox
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1", # строго в пределах области (region 66)
},
)
response.raise_for_status()
data = response.json()
oblast_fallback: dict | None = None
for item in data:
try:
lat_f = float(item["lat"])
lon_f = float(item["lon"])
if is_within_ekb_bbox(lat_f, lon_f):
return item
except Exception:
continue
return None
if _nominatim_region_ok(item) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = item
return oblast_fallback
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
@ -202,8 +390,8 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
Бан-policy: User-Agent с email обязателен.
Tier 1: bounded ЕКБ на оригинальный адрес.
Tier 2: bounded ЕКБ на typo-варианты (Цвилинга Цвиллинга).
Tier 1: bounded область (region 66) на оригинальный адрес.
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга Цвиллинга).
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
@ -237,6 +425,30 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
)
def _yandex_region_ok(geo_object: dict) -> bool | None:
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
область. None если поля нет / оно не строка тогда accept-логика падает
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск они внутри
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
"""
try:
admin_area = (
geo_object.get("metaDataProperty", {})
.get("GeocoderMetaData", {})
.get("AddressDetails", {})
.get("Country", {})
.get("AdministrativeArea", {})
.get("AdministrativeAreaName")
)
except AttributeError:
return None
if not isinstance(admin_area, str) or not admin_area:
return None
return "свердловск" in admin_area.lower()
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
@ -247,8 +459,9 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
но БЕЗ rspn чтобы fuzzy matching работал при опечатках.
"""
# Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}"
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
# (типичный кейс из suggest, либо явный запрос по другому городу области).
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
"https://geocode-maps.yandex.ru/1.x/",
@ -269,22 +482,44 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
if not members:
return None
# Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
best = None
oblast_fallback = None
for m in members:
obj = m.get("GeoObject", {})
try:
lon_str, lat_str = obj["Point"]["pos"].split()
lat_f, lon_f = float(lat_str), float(lon_str)
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
except Exception:
continue
if _yandex_region_ok(obj) is False:
continue # регион явно не Свердловская область — не рассматриваем
if is_within_ekb_bbox(lat_f, lon_f):
best = obj
break
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
oblast_fallback = obj
if best is None:
# Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
best = members[0]["GeoObject"]
best = oblast_fallback
if best is None:
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
# отсеяны по региону) → берём первый «как есть», НО только если его
# регион не опровергнут явно — иначе весь смысл region cross-check
# теряется на garbage-query, где top-1 — гарантированно не тот регион
# (например, Тюмень).
first_obj = members[0]["GeoObject"]
if _yandex_region_ok(first_obj) is not False:
best = first_obj
if best is None:
return None
lon_str, lat_str = best["Point"]["pos"].split()
precision_raw = (
@ -310,6 +545,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
EKB_BBOX = {
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
"ll": "60.605,56.838", # Yandex center (lon,lat)
@ -375,9 +612,13 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
Без city suffix query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
Constraint вся область (region='Свердловская область', hard-filter внутри
`suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/Серов/etc
никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(query, limit=limit)
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
out: list[GeocodeSuggestion] = []
for s in raw:
if s.lat is None or s.lon is None:
@ -424,16 +665,19 @@ async def _yandex_geocode_request(
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
Tier 1: bounded ЕКБ (rspn=1) на оригинальный query.
Tier 1: bounded ЕКБ (rspn=1) быстрый путь для основного (ЕКБ) трафика.
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
Tier 3: без rspn fuzzy по всей стране, фильтр результатов по bbox области
(region 66) ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
(bounded строго ЕКБ) структурно вернуть не могут.
"""
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
async with httpx.AsyncClient(timeout=8.0) as client:
# Tier 1: strict bounded на оригинал
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
members = await _yandex_geocode_request(
client,
api_key,
f"Екатеринбург, {query}",
prefixed_query,
limit,
bounded=True,
)
@ -441,12 +685,15 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
if results:
return results
# Tier 2: bounded на typo-варианты
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
for variant in _typo_variants(query, limit=4):
variant_query = (
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
)
members = await _yandex_geocode_request(
client,
api_key,
f"Екатеринбург, {variant}",
variant_query,
limit,
bounded=True,
)
@ -454,21 +701,21 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
if results:
return results
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
members = await _yandex_geocode_request(
client,
api_key,
f"Екатеринбург, {query}",
prefixed_query,
limit,
bounded=False,
)
results = _parse_yandex_members(members)
in_ekb = [r for r in results if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95]
return in_ekb
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
return in_oblast
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
response = await client.get(
"https://nominatim.openstreetmap.org/search",
params={
@ -476,7 +723,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
"format": "json",
"limit": str(limit),
"countrycodes": "ru",
"viewbox": EKB_BBOX["viewbox"],
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"bounded": "1",
"addressdetails": "1",
},
@ -488,21 +735,29 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен)."""
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
региона 66 иначе не режем явные запросы по другим городам области.
"""
headers = {
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query
data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
data = await _nominatim_query_multi(client, suffixed_query, limit)
# Tier 2: typo-варианты если оригинал пустой
if not data:
for variant in _typo_variants(query, limit=3):
await asyncio.sleep(1.0) # Nominatim 1 req/sec
data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit)
variant_query = (
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
)
data = await _nominatim_query_multi(client, variant_query, limit)
if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
break
@ -832,7 +1087,8 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → [].
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Yandex Nominatim [].
db: если передан cadastral lookup через gendesign_cad_buildings (первый tier).
DaData /suggest (PR Q2) token-only, 10k/день, заменяет Yandex который
@ -842,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
if not query or len(query.strip()) < 2:
return []
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
if db is not None:
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
# EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
# другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
# с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
# oblast-aware.
if db is not None and not _names_non_ekb_city(query):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip())
@ -910,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# 2. Локальные источники по street+house (без внешнего API).
parsed = _parse_street_house(address.strip())
# #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
use_local_ekb = not _names_non_ekb_city(address)
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
if parsed is not None:
if use_local_ekb and parsed is not None:
street, house = parsed
try:
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
@ -939,7 +1205,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
# (литеральная подстрока не совпадает).
if parsed is not None:
if use_local_ekb and parsed is not None:
street, house = parsed
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
if hit is not None:
@ -960,29 +1226,32 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
return result
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
if cad_suggestions:
s = cad_suggestions[0]
result = GeocodeResult(
lat=s.lat,
lon=s.lon,
full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
if use_local_ekb:
cad_suggestions = await asyncio.to_thread(
_cadastral_forward_sync, db, address.strip(), limit=1
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
if cad_suggestions:
s = cad_suggestions[0]
result = GeocodeResult(
lat=s.lat,
lon=s.lon,
full_address=s.full_address,
provider="nominatim", # treat as "local" — same confidence as nominatim
confidence="exact",
)
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
)
return result
# 3. Yandex (если есть key) с typo-fallback
if settings.yandex_geocoder_api_key:
try:
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
# Если результат вне ЕКБ — пробуем typo-варианты
in_ekb = (
result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95
)
if result is not None and in_ekb:
# Если результат вне области (region 66) — пробуем typo-варианты
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
if result is not None and in_oblast:
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
return result
@ -994,7 +1263,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
continue
if result is None:
continue
if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
if is_within_oblast66_bbox(result.lat, result.lon):
await asyncio.to_thread(_cache_put, db, addr_norm, result)
logger.info(
"geocode yandex typo-fixed: %s%s → (%.5f, %.5f)",
@ -1128,11 +1397,12 @@ async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocod
for p in parts
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
]
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
locality = None
rest: list[str] = []
for p in filtered:
if locality is None and p in {"Екатеринбург"}:
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
locality = p
else:
rest.append(p)

View file

@ -24,6 +24,24 @@ logger = logging.getLogger(__name__)
# small enough to bound storage & decode cost.
_MAX_DIMENSION = 2400
# Hard cap on declared pixel count (width*height) enforced BEFORE any decode.
# A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load()
# inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert
# copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots.
# 40 MP (~8000×5000) sits far above any real listing photo yet well below the
# ~89 MP default Pillow bomb threshold. img.size is read from the header on
# open() without decoding pixels, so this check is cheap and pre-decode.
_MAX_PIXELS = 40_000_000
# Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this
# service is standalone; keep it self-defending against oversized inputs).
_MAX_BYTES = 10 * 1024 * 1024
# Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its
# ~89 MP default to our cap, so oversized declared dimensions raise even on any
# decode path that bypasses the explicit size guard below.
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS
# JPEG quality. 85 is industry standard for photos — visually lossless,
# ~70% size reduction vs original phone JPEGs.
_JPEG_QUALITY = 85
@ -46,10 +64,28 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
(sanitized_bytes, "image/jpeg") both ready for storage/serving.
Raises:
ImageSanitizationError: bytes are not a recognized image format.
ImageSanitizationError: bytes are not a recognized image format, or the
declared dimensions / byte size exceed the pixel-flood safety caps.
"""
if len(content) > _MAX_BYTES:
raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap")
try:
with Image.open(io.BytesIO(content)) as img:
# #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes
# from the header (no pixel decode); loading an oversized declared
# image would inflate a small file into a hundreds-of-MB RGB buffer.
width, height = img.size
if width * height > _MAX_PIXELS:
logger.warning(
"image rejected: %dx%d (%d px) exceeds %d px cap",
width,
height,
width * height,
_MAX_PIXELS,
)
raise ImageSanitizationError(
f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap"
)
img.load() # force full decode; surfaces UnidentifiedImageError early
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
@ -65,10 +101,16 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
# save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
return buf.getvalue(), SANITIZED_CONTENT_TYPE
except ImageSanitizationError:
raise # already a handled, user-facing rejection (byte/pixel cap)
except UnidentifiedImageError as e:
raise ImageSanitizationError("not a recognized image format") from e
except Image.DecompressionBombError as e:
# Any decode path that outran the size guard above (e.g. >2× cap) lands here.
logger.warning("decompression bomb rejected: %s", e)
raise ImageSanitizationError("image dimensions exceed safe limits") from e
except Exception as e:
# PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...);
# normalize to one user-facing error type.
# PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
# promoted to error, ...); normalize to one user-facing error type.
logger.warning("image sanitization failed: %s", e)
raise ImageSanitizationError(f"image decode failed: {e}") from e

View file

@ -53,11 +53,35 @@ CATEGORY_WEIGHTS: dict[str, float] = {
DEFAULT_RADIUS_M = 1200
DEFAULT_TOP_N = 7
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
# Теоретический максимум суммы весов top-7 POI.
#
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
#
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
#
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
_REF_DISTANCE_M: float = 100.0
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
_REF_DISTANCE_M + 100.0
) # ≈ 0.1575 (было 0.315 при d=0)
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).

View file

@ -16,14 +16,25 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.matching.normalize import (
EKB_CITY_TOKEN,
address_fingerprint,
has_city_token,
has_house_number,
house_number_token,
normalize_address,
resolve_city_token,
)
logger = logging.getLogger(__name__)
# Tier-2b oblast guard radius (metres). A bare street+number normalized_address carries
# NO city token yet is globally unique in house_address_aliases, so 'улица ленина 100'
# physically exists in ЕКБ, Н.Тагил, Каменск… When the incoming card HAS coords we accept
# the alias only if its house geom is within this radius. Cities in обл.66 are ≥ ~9-15 km
# apart, so 3 km cleanly separates them while absorbing the intra-city geocoder drift that
# Tier-2b exists to bridge (same building, different provider coords).
_TIER2B_GUARD_M = 3000
def match_or_create_house(
db: Session,
@ -180,30 +191,95 @@ def match_or_create_house(
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
# and each potentially create a duplicate house row.
row = (
db.execute(
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"),
{"fp": fp},
#
# Tier-2a/2b oblast city guard (#2500 follow-up, closes the Tier-2a mis-bucket that the
# #2500 Tier-2b geo-guard could not reach): a coord-less card whose address explicitly
# resolves a NON-ЕКБ обл.66 city must NOT match a globally-unique alias (fingerprint OR
# normalized_address) that almost certainly belongs to ЕКБ/another city — that would
# corrupt ЕКБ house data. When it fires we skip both alias lookups and fall through to a
# New house (Tier 3 is coord-gated, so coord-less cards skip it too).
#
# This PREVENTS ЕКБ CORRUPTION. Full oblast-internal dedup (deduping two listings of the
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
# scope, only relevant once the oblast sweep is enabled.
#
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
# residual needs sweep-context and is out of this fix's scope.
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
if _skip_oblast_alias:
logger.info(
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
_resolved_city,
norm_addr,
ext_source,
)
.mappings()
.first()
)
if row is None and norm_addr and has_num:
# Tier 2b: same normalized address, possibly different coords fingerprint.
# Gated on has_num (P1): never match a bare-street normalized_address — any
# numberless listing would otherwise collapse into whichever house first
# registered that street.
row = None
if not _skip_oblast_alias:
row = (
db.execute(
text(
"SELECT house_id FROM house_address_aliases "
"WHERE normalized_address = :na LIMIT 1"
),
{"na": norm_addr},
text("SELECT house_id FROM house_address_aliases WHERE fingerprint = :fp LIMIT 1"),
{"fp": fp},
)
.mappings()
.first()
)
if row is None and norm_addr and has_num and not _skip_oblast_alias:
# Tier 2b: same normalized address, possibly different coords fingerprint.
# Gated on has_num (P1): never match a bare-street normalized_address — any
# numberless listing would otherwise collapse into whichever house first
# registered that street.
#
# Oblast guard (bug #2, обл.66 per-city rollout): normalized_address has NO city
# token, so a bare street+number is globally unique in the alias table yet exists
# physically in several cities. Accept the match only when the incoming listing is
# confirmably the same place:
# • coords present → aliased house geom within _TIER2B_GUARD_M metres;
# • coords absent → norm_addr carries a city token (self-disambiguating —
# 'екатеринбург улица ленина 5' can't collide with a Н.Тагил address);
# • neither → skip. A bare common-street with no geo signal is too
# ambiguous — matching would mis-bucket an oblast card into a same-named ЕКБ
# house. Conservative: fall through to a New house rather than mis-match.
if lat is not None and lon is not None:
row = (
db.execute(
text(
"SELECT a.house_id FROM house_address_aliases a "
"JOIN houses h ON h.id = a.house_id "
"WHERE a.normalized_address = :na "
" AND h.geom IS NOT NULL "
" AND ST_DWithin(h.geom::geography, "
" ST_MakePoint(:lon, :lat)::geography, :thr) "
"LIMIT 1"
),
{"na": norm_addr, "lon": lon, "lat": lat, "thr": _TIER2B_GUARD_M},
)
.mappings()
.first()
)
elif has_city_token(norm_addr):
row = (
db.execute(
text(
"SELECT house_id FROM house_address_aliases "
"WHERE normalized_address = :na LIMIT 1"
),
{"na": norm_addr},
)
.mappings()
.first()
)
else:
logger.info(
"house tier2b skip: bare common-street, no coords/city na=%r src=%s",
norm_addr,
ext_source,
)
if row:
house_id = int(row["house_id"])
_upsert_house_source(
@ -520,6 +596,12 @@ def _insert_alias(
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
house_id is not updated on conflict: the first writer wins canonical ownership.
Oblast guard (bug #2): the fingerprint/source are refreshed ONLY when the conflicting
row belongs to the SAME house. A DIFFERENT house colliding on a bare common-street
normalized_address (an oblast card that fell through Tier-2b's guard to a New INSERT)
must NOT rewrite the owner's fingerprint — doing so would downgrade a safe coord-bearing
alias to a coord-less one and let the next no-coord card mis-bucket via Tier 2a.
P1: a bare-street normalized_address (no house number) is NOT registered as an
alias it is too ambiguous to serve as a building key. Any later numberless
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
@ -533,8 +615,12 @@ def _insert_alias(
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
VALUES (CAST(:hid AS bigint), :na, :fp, :src)
ON CONFLICT (normalized_address) DO UPDATE SET
fingerprint = EXCLUDED.fingerprint,
source = EXCLUDED.source
fingerprint = CASE
WHEN house_address_aliases.house_id = EXCLUDED.house_id
THEN EXCLUDED.fingerprint ELSE house_address_aliases.fingerprint END,
source = CASE
WHEN house_address_aliases.house_id = EXCLUDED.house_id
THEN EXCLUDED.source ELSE house_address_aliases.source END
"""),
{
"hid": house_id,

View file

@ -112,6 +112,62 @@ def house_number_token(normalized: str | None) -> str | None:
return m.group(1) if m else None
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
EKB_CITY_TOKEN = "екатеринбург"
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,
# e.g. 'каменск-уральский' -> 'каменск уральский'). Mirrors CITY_ANCHORS in
# scraper_kit.orchestration.pipeline — extend together as the sweep adds cities.
# EKB_CITY_TOKEN must be first (leftmost-alternation determinism / documentation).
_CITY_TOKENS: tuple[str, ...] = (
EKB_CITY_TOKEN,
"нижний тагил",
"каменск уральский",
"первоуральск",
"верхняя пышма",
"серов",
)
# Token-bounded (not substring) so a street named after a city does NOT false-positive:
# 'улица серова 5' has token 'серова' (trailing 'а'), never bare 'серов'.
_CITY_TOKEN_RE = re.compile(
r"(?:^|\s)(?:" + "|".join(re.escape(c) for c in _CITY_TOKENS) + r")(?:\s|$)"
)
def resolve_city_token(normalized: str | None) -> str | None:
"""Return the обл.66 city token present in the normalized address, else None.
Token-bounded (see _CITY_TOKEN_RE) so a street named after a city never matches
('улица серова 5' -> None, not 'серов'). When the address names ЕКБ, returns
EKB_CITY_TOKEN; a non-ЕКБ oblast city returns its own token. Used by the Tier-2a
oblast guard in match_or_create_house: a coord-less card that resolves a NON-ЕКБ city
must not fingerprint/normalized_address-match a globally-unique alias that (almost
certainly) belongs to ЕКБ/another city that would corrupt ЕКБ house data.
"""
if not normalized:
return None
m = _CITY_TOKEN_RE.search(normalized)
if m is None:
return None
# group(0) carries the (?:^|\s)...(?:\s|$) boundary whitespace — strip it to the token.
return m.group(0).strip()
def has_city_token(normalized: str | None) -> bool:
"""True if the normalized address carries a known обл.66 city token.
A city token makes the normalized_address city-scoped two addresses in different
cities can then never collapse to the same key, so the alias is a safe cross-source
building key. Used by the Tier-2b oblast guard in match_or_create_house: a bare
common-street address with NO city token AND no coords is too ambiguous to match a
globally-unique normalized_address alias (it would mis-bucket an oblast card into a
same-named ЕКБ house bug #2 oblast rollout).
"""
return resolve_city_token(normalized) is not None
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
"""SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).

View file

@ -193,6 +193,16 @@ async def _job_deals_freshness_monitor(
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.sber_freshness_monitor import check_sber_freshness
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, check_sber_freshness, db, run_id, params)
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
async def _job_newbuilding_enrich(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -394,6 +404,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -5,31 +5,62 @@
открытых данных Росреестра.
- ALERT: залогировать actionable-сообщение, если квартал доступен.
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders это ручной
ops-шаг. После алерта оператор запускает:
ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
репозитория при смене базового пути их тоже нужно поправить вручную):
data/sql/02_load_all_quarters.sh
tradein-mvp/deploy/import-rosreestr.sh
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
https://rosreestr.gov.ru/opendata/
ИСТОРИЯ (важно для будущих правок): раньше архив открытых данных Росреестра
(сделки ДКП/ДДУ) публиковался на https://rosreestr.gov.ru/opendata/ с индексом
/opendata/f.json. Этот портал был переделан на Bitrix прямые ссылки на ZIP
теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а
/opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой
HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ
квартал как доступный это и есть баг #issue, который чинит этот модуль.
Файлы именуются по шаблону:
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
АКТУАЛЬНОЕ (2026-07) расположение датасетов открытый Apache autoindex без
авторизации:
https://rosreestr.gov.ru/data-sets/
Структура:
- Папки кварталов названы в кодировке Windows-1251 (percent-encoded href
именно cp1251, НЕ utf-8), например "1 квартал 2026г."
href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка
"Архив до 2023г. включительно/" для старых периодов.
- Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
(тоже cp1251-кодированный href, например
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом
dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши).
Индексный JSON живёт по адресу:
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
расставляют руками, возможны отличия в пробелах/пунктуации между кварталами)
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
имени. См. check_new_quarter_available().
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
Формат ссылки:
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
недостаточен.
При сетевой ошибке / HTTP 5xx логируем warning, возвращаем available=False.
Статус 404 квартал не опубликован available=False (штатный случай до начала августа).
rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
доверия в стандартном trust store соединение делаем с verify=False, как
sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные
неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также
отвечает HTTP 403 без браузерного User-Agent шлём Chrome UA (тот же паттерн,
что DEFAULT_UA в zhkh_flats_loader.py).
При сетевой ошибке / HTTP 5xx / таймауте логируем warning, возвращаем
available=False. Отсутствие папки/файла квартала available=False (штатный
случай до публикации квартала, до начала следующего месяца после конца квартала).
"""
from __future__ import annotations
import logging
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
import httpx
from sqlalchemy import text
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала).
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru.
_ROSREESTR_DATASET_URL_TEMPLATE = (
"https://rosreestr.gov.ru/opendata/"
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
# Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
_DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
# Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
# Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py.
_BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
# Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла.
_MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB
# Таймаут запросов. Росреестр может быть медленным — 15s достаточно.
_HTTP_TIMEOUT = 15.0
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
"""Вернуть (year, quarter) для следующего квартала.
@ -117,11 +156,68 @@ def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
def rosreestr_dataset_url(year: int, quarter: int) -> str:
"""Сформировать URL датасета Росреестра для заданного квартала.
"""Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору).
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования
папка "{quarter} квартал {year}г.", файл
"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" закодированная как
cp1251 percent-encoding (подтверждено вручную против реального URL портала).
Она НЕ используется для самой проверки доступности: check_new_quarter_available()
парсит реальный autoindex и не полагается на угаданный URL, потому что
Росреестр расставляет имена папок вручную и они могут отличаться в мелочах
(лишний пробел, пунктуация) от квартала к кварталу.
Если угаданная ссылка не откроется ориентир для оператора: _DATA_SETS_BASE_URL
(https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами.
"""
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
folder_name = f"{quarter} квартал {year}г."
file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
folder_enc = quote(folder_name.encode("cp1251"), safe="")
file_enc = quote(file_name.encode("cp1251"), safe="")
return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}"
def _decode_cp1251_href(href: str) -> str:
"""Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8)."""
try:
return unquote(href, encoding="cp1251", errors="strict")
except (UnicodeDecodeError, LookupError):
# href без кириллицы (например "../") или неожиданная кодировка — fallback.
return unquote(href)
def _normalize_ws(s: str) -> str:
"""Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную."""
return " ".join(s.split())
def _extract_hrefs(html: str) -> list[str]:
"""Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML)."""
return _HREF_RE.findall(html)
def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None:
"""Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/.
Сравнение по декодированному (cp1251) и нормализованному по пробелам имени
папки, ожидаемый паттерн "{quarter} квартал {year}г.".
"""
expected_name = f"{quarter} квартал {year}г."
for href in _extract_hrefs(html):
decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/"))
if decoded == expected_name:
return href
return None
def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None:
"""Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала."""
expected_suffix = f"_y_{year}_q_{quarter}.csv.zip"
for href in _extract_hrefs(html):
decoded = _decode_cp1251_href(href)
if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix):
return href
return None
async def check_new_quarter_available(
@ -129,48 +225,109 @@ async def check_new_quarter_available(
year: int,
quarter: int,
) -> bool:
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
"""Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter).
HEAD-запрос к прямой ссылке на ZIP без скачивания файла.
Шаги (см. докстринг модуля за подробностями):
1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному
(cp1251) имени "{quarter} квартал {year}г.".
2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip.
3. HEAD найденный файл считаем ДОСТУПНЫМ, только если Content-Type
начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES.
Голый HTTP 200 НЕ считается доступностью это была причина бага
(Bitrix-заглушка /opendata/ тоже отвечала 200).
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
Отсутствие папки/файла квартала available=False (нормально до публикации).
Сетевая ошибка / таймаут / неожиданное исключение warning, available=False.
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
"""
url = rosreestr_dataset_url(year, quarter)
try:
resp = await client.head(url, follow_redirects=True)
if resp.status_code == 200:
logger.info(
"rosreestr_poll: Q%d %d available at %s (HTTP %d)",
quarter,
year,
url,
resp.status_code,
)
return True
if resp.status_code == 404:
logger.info(
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
if index_resp.status_code != 200:
logger.warning(
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
index_resp.status_code,
_DATA_SETS_BASE_URL,
quarter,
year,
)
return False
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
logger.warning(
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable",
resp.status_code,
folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
if folder_href is None:
logger.info(
"rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published",
quarter,
year,
_DATA_SETS_BASE_URL,
)
return False
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
folder_resp = await client.get(folder_url, follow_redirects=True)
if folder_resp.status_code != 200:
logger.warning(
"rosreestr_poll: unexpected HTTP %d listing folder %s"
"treating Q%d %d as unavailable",
folder_resp.status_code,
folder_url,
quarter,
year,
)
return False
file_href = _find_dataset_file_href(folder_resp.text, year, quarter)
if file_href is None:
logger.info(
"rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s",
quarter,
year,
folder_url,
)
return False
file_url = urljoin(folder_url, file_href)
file_resp = await client.head(file_url, follow_redirects=True)
content_type = file_resp.headers.get("content-type", "")
try:
content_length = int(file_resp.headers.get("content-length") or "0")
except ValueError:
content_length = 0
if (
file_resp.status_code == 200
and content_type.startswith("application/zip")
and content_length > _MIN_DATASET_SIZE_BYTES
):
logger.info(
"rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)",
quarter,
year,
file_url,
content_type,
content_length,
)
return True
logger.info(
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
"treating as unavailable",
quarter,
year,
url,
file_url,
file_resp.status_code,
content_type,
content_length,
)
return False
except httpx.TimeoutException:
logger.warning(
"rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable",
"rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable",
quarter,
year,
url,
_DATA_SETS_BASE_URL,
)
return False
except httpx.RequestError as exc:
@ -197,7 +354,7 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
Шаги:
1. Найти MAX(deal_date) WHERE source='rosreestr' (loaded_year, loaded_quarter).
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
3. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type).
4. Если доступен логировать actionable INFO с инструкцией по запуску ingest.
5. Вернуть dict с результатом.
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
loaded_year,
)
# 3. Проверка наличия
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:
# 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
# браузерный UA: без него rosreestr.gov.ru отвечает 403)
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
verify=False,
headers={"User-Agent": _BROWSER_USER_AGENT},
) as client:
available = await check_new_quarter_available(client, next_year, next_quarter)
# 4. Алерт если доступен
if available:
logger.info(
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. "
"Run ingest to load: "
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh",
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
"Run ingest to load: data/sql/02_load_all_quarters.sh + "
"tradein-mvp/deploy/import-rosreestr.sh "
"(both live outside this repo — if they still point at the old "
"rosreestr.gov.ru/opendata/ base, update them to %s first)",
next_quarter,
next_year,
rosreestr_dataset_url(next_year, next_quarter),
_DATA_SETS_BASE_URL,
)
return {

View file

@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
def compute_next_run_at(
window_start_hour: int,
@ -180,9 +185,16 @@ def import_rosreestr_dkp(
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
сидящих в source foreign table). address и deals.city строятся из реального city
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
источника (= 66 при текущем фильтре).
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
- region_code = 66 (Свердловская область)
- city ILIKE '%катеринбург%'
- region_code = 66 (вся Свердловская область, все города)
- city IS NOT NULL AND trim(city) != '' (непустой город корректный address)
- realestate_type_code = '002001003000' (квартира)
- area BETWEEN 18 AND 200
- deal_price BETWEEN 1000000 AND 100000000
@ -210,7 +222,16 @@ def import_rosreestr_dkp(
counters: dict[str, int] = {
"rows_fetched": 0,
"rows_inserted": 0,
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
# (исправленный/переопубликованный квартал — Fix D).
"rows_updated": 0,
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
# идентичны — DO UPDATE ... WHERE distinct не сработал).
"rows_skipped": 0,
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
# как обычный дедуп и прогон рапортовал success.
"rows_errored": 0,
"batches_done": 0,
}
@ -248,8 +269,9 @@ def import_rosreestr_dkp(
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
# пере-сканирует с id=0 (INSERT ... ON CONFLICT DO NOTHING идемпотентен)
# → уже вставленные строки пропускаются, остаток до-импортируется.
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
# mark_done выводит run из 'running' → reap_zombies его не тронет.
logger.info(
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
@ -273,7 +295,9 @@ def import_rosreestr_dkp(
id,
id AS source_id_src,
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
'Екатеринбург, ' || trim(street) AS address,
trim(city) || ', ' || trim(street) AS address,
region_code,
trim(city) AS city,
CASE
WHEN area < 30 THEN 0
WHEN area < 44 THEN 1
@ -296,7 +320,7 @@ def import_rosreestr_dkp(
period_start_date AS deal_date
FROM gendesign_rosreestr_deals
WHERE region_code = 66
AND city ILIKE '%катеринбург%'
AND city IS NOT NULL AND trim(city) <> ''
AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000
@ -318,7 +342,9 @@ def import_rosreestr_dkp(
total_batches += 1
batch_inserted = 0
batch_updated = 0
batch_skipped = 0
batch_errored = 0
batch_max_id = last_id
for row in batch_rows:
@ -328,17 +354,30 @@ def import_rosreestr_dkp(
try:
with db.begin_nested(): # SAVEPOINT per row
inserted = db.execute(
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
# неизменные строки нетронутыми (идемпотентность resume: строка
# без изменений → 0 returned → legit dedup-skip). source/source_id
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
result = db.execute(
text("""
INSERT INTO deals (
source, dedup_hash, source_id, address, rooms, area_m2,
floor, year_built, price_rub, price_per_m2, deal_date
source, dedup_hash, source_id, address, region_code, city,
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
deal_date
)
VALUES (
'rosreestr',
CAST(:dedup_hash AS text),
CAST(:source_id AS text),
CAST(:address AS text),
CAST(:region_code AS int),
CAST(:city AS text),
CAST(:rooms AS int),
CAST(:area_m2 AS numeric),
CAST(:floor_num AS int),
@ -347,13 +386,35 @@ def import_rosreestr_dkp(
CAST(:price_per_m2 AS int),
CAST(:deal_date AS date)
)
ON CONFLICT (dedup_hash) DO NOTHING
RETURNING id
ON CONFLICT (dedup_hash) DO UPDATE SET
address = EXCLUDED.address,
region_code = EXCLUDED.region_code,
city = EXCLUDED.city,
rooms = EXCLUDED.rooms,
area_m2 = EXCLUDED.area_m2,
floor = EXCLUDED.floor,
year_built = EXCLUDED.year_built,
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2,
deal_date = EXCLUDED.deal_date
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
OR deals.city IS DISTINCT FROM EXCLUDED.city
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
RETURNING (xmax = 0) AS was_inserted
"""),
{
"dedup_hash": row["dedup_hash"],
"source_id": str(row["source_id_src"]),
"address": row["address"],
"region_code": row["region_code"],
"city": row["city"],
"rooms": row["rooms"],
"area_m2": row["area_m2"],
"floor_num": row["floor_num"],
@ -363,25 +424,33 @@ def import_rosreestr_dkp(
"deal_date": row["deal_date"],
},
).fetchone()
if inserted is not None:
if result is None:
# DO UPDATE ... WHERE distinct не сработал → строка есть и
# факты идентичны = легитимный dedup-skip (Fix C: только это
# теперь считается skip, INSERT-ошибки — отдельно ниже).
batch_skipped += 1
elif result[0]:
batch_inserted += 1
else:
batch_skipped += 1
batch_updated += 1
except Exception as exc:
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
logger.warning(
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
run_id,
row_id,
exc,
)
batch_skipped += 1
batch_errored += 1
last_id = batch_max_id
db.commit()
counters["rows_fetched"] += len(batch_rows)
counters["rows_inserted"] += batch_inserted
counters["rows_updated"] += batch_updated
counters["rows_skipped"] += batch_skipped
counters["rows_errored"] += batch_errored
counters["batches_done"] = total_batches
counters["last_id"] = last_id # type: ignore[assignment]
@ -389,26 +458,42 @@ def import_rosreestr_dkp(
runs_mod.update_heartbeat(db, run_id, counters)
logger.info(
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
"inserted=%d skipped=%d last_id=%d",
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
run_id,
total_batches,
len(batch_rows),
batch_inserted,
batch_updated,
batch_skipped,
batch_errored,
last_id,
)
if len(batch_rows) < batch_size:
break # Last partial batch — no more rows
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
fetched = counters["rows_fetched"]
errored = counters["rows_errored"]
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
raise RuntimeError(
f"rosreestr_dkp_import: per-row INSERT error rate "
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"rosreestr_dkp_import run_id=%d done: "
"total_fetched=%d inserted=%d skipped=%d batches=%d",
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
run_id,
counters["rows_fetched"],
counters["rows_inserted"],
counters["rows_updated"],
counters["rows_skipped"],
counters["rows_errored"],
total_batches,
)

View file

@ -0,0 +1,146 @@
"""Сервис записи в `user_events` — Feature 2 (login/IP audit) + база для Feature 3
(behavior analytics).
`user_events` (migration `184_user_events.sql`) unified append-only event log,
admin-read-only. Запись события НИКОГДА не должна ронять реальный HTTP-запрос:
любая ошибка (БД недоступна, сетевой сбой, схема разъехалась) ловится и логируется
через `logger.warning`, без re-raise вызывающий код (middleware / handler)
продолжает работать так, как если бы аудит был выключен.
"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
def record_event(
*,
event_type: str,
username: str,
ip: str | None = None,
user_agent: str | None = None,
path: str | None = None,
method: str | None = None,
estimate_id: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
"""Синхронно вставляет одну строку в `user_events`.
Открывает СОБСТВЕННУЮ сессию (`SessionLocal()`), декаплённую от транзакции
вызывающего запроса событие коммитится независимо и переживает rollback
основного хендлера (и наоборот: сбой записи события не трогает основную tx,
т.к. она уже закоммичена/не связана с этой сессией).
Никогда не поднимает исключение вызывающий код (middleware / endpoint)
не должен падать из-за проблем с аудит-логом.
"""
try:
with SessionLocal() as db:
db.execute(
text(
"""
INSERT INTO user_events
(event_type, username, ip_address, user_agent, path, method,
estimate_id, payload)
VALUES
(:event_type, :username, CAST(:ip AS inet), :user_agent, :path,
:method, CAST(:estimate_id AS uuid), CAST(:payload AS jsonb))
"""
),
{
"event_type": event_type,
"username": username,
"ip": ip,
"user_agent": user_agent,
"path": path,
"method": method,
"estimate_id": estimate_id,
"payload": json.dumps(payload or {}, ensure_ascii=False),
},
)
db.commit()
except Exception:
logger.warning(
"user_events: failed to record event_type=%r username=%r path=%r",
event_type,
username,
path,
exc_info=True,
)
def schedule_event(**kwargs: Any) -> None:
"""Fire-and-forget обёртка над `record_event` — никогда не блокирует запрос и
никогда не поднимает исключение наружу.
- Есть running event loop (обычный async FastAPI handler / middleware)
INSERT уводится в отдельный поток через `asyncio.to_thread`, вызывающая
корутина не ждёт результата. Ошибки ловятся done-callback'ом и просто
логируются (иначе asyncio ругался бы "Task exception was never retrieved").
- Нет running loop (sync-контекст: тесты, Celery task, скрипт) вызывает
`record_event(**kwargs)` синхронно inline (она сама никогда не raises).
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
record_event(**kwargs)
return
try:
task = loop.create_task(asyncio.to_thread(record_event, **kwargs))
except Exception:
logger.warning("user_events: failed to schedule background event", exc_info=True)
return
def _log_task_exception(t: asyncio.Task[None]) -> None:
if t.cancelled():
return
exc = t.exception()
if exc is not None:
logger.warning("user_events: background record_event failed: %s", exc)
task.add_done_callback(_log_task_exception)
# ---------------------------------------------------------------------------
# In-process best-effort dedup для login-события.
# ---------------------------------------------------------------------------
_LOGIN_DEDUP_LOCK = threading.Lock()
_LOGIN_DEDUP_SEEN: set[str] = set()
_LOGIN_DEDUP_MAX = 50_000
def should_log_login(username: str, ip: str | None, user_agent: str | None) -> bool:
"""True максимум один раз в сутки на комбинацию (username, ip, user_agent) в
рамках жизни процесса держит объём `event_type='login'` строк на уровне
~одной записи на аккаунт+устройство в день, а не одной на каждый API-запрос.
Best-effort/не персистентно: рестарт процесса (деплой, worker respawn)
сбрасывает in-memory set, так что после рестарта первая комбинация дня
залогируется заново недо-дедуп, не потеря данных (для отчётов по
user_events всё равно используется `DISTINCT (username, ip_address,
user_agent, date_trunc('day', created_at))`, так что дубликаты не искажают
метрики, лишь чуть увеличивают объём записи).
"""
day = datetime.now(UTC).strftime("%Y-%m-%d")
key = f"{username}|{ip or ''}|{user_agent or ''}|{day}"
with _LOGIN_DEDUP_LOCK:
if key in _LOGIN_DEDUP_SEEN:
return False
if len(_LOGIN_DEDUP_SEEN) >= _LOGIN_DEDUP_MAX:
_LOGIN_DEDUP_SEEN.clear()
_LOGIN_DEDUP_SEEN.add(key)
return True

View file

@ -40,6 +40,14 @@ from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
_PPM2_MIN: int = 30_000
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
# ratio (0.877→0.62, «выкупная» 29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4)
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
@ -213,7 +223,11 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
db.execute(_DELETE_SQL)
db.execute(
_REDERIVE_SQL,
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
{
"ppm2_min": _PPM2_MIN,
"ppm2_max": settings.asking_ratio_ppm2_max,
"asking_city": _ASKING_CITY_PATTERN,
},
)
row = db.execute(_COUNTERS_SQL).mappings().first()

View file

@ -0,0 +1,124 @@
"""CLI: ДОМ.РФ капремонт open data → houses.year_built/material_walls (issue #2013).
Тянет КР1.1 (house registry) + КР1.2 (constructive elements) с открытых данных
ДОМ.РФ (region 66), UPSERT'ит в staging `domrf_kapremont` (мигр. 176), затем:
1. backfill_houses_from_domrf COALESCE(houses.year_built, domrf.commission_year,
houses.zhkh_year) + material_walls + total_floors (fold-in уже загруженного
ГИС-ЖКХ года houses без domrf-матча тоже выигрывают).
2. propagate_listings_year_from_houses UPDATE listings.year_built = houses.year_built
там, где listing ещё NULL. Именно это открывает estimator'у (year_built BETWEEN
когорта-фильтр) реальные годы на источниках вроде avito (0% year_built, 52.5%
linked к houses).
Запуск из контейнера tradein-backend (у прода есть интернет к домрф.рф):
python -m app.tasks.domrf_kapremont_load # полный прогон
python -m app.tasks.domrf_kapremont_load --dry-run # без записи, только подсчёт
python -m app.tasks.domrf_kapremont_load --load-only # только staging, без backfill
python -m app.tasks.domrf_kapremont_load --backfill-only # staging уже загружена — только
# backfill+propagate
python -m app.tasks.domrf_kapremont_load \\
--kr11-path /tmp/kr11.csv --kr12-path /tmp/kr12.csv # локальные CSV, без скачивания
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
в БД не делается.
"""
from __future__ import annotations
import argparse
import logging
from app.core.db import SessionLocal
from app.services.domrf_kapremont_loader import (
backfill_houses_from_domrf,
load_domrf_kapremont,
propagate_listings_year_from_houses,
)
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description=(
"ДОМ.РФ капремонт loader: КР1.1+КР1.2 → domrf_kapremont staging → "
"houses.year_built/material_walls/total_floors → listings.year_built"
)
)
parser.add_argument(
"--kr11-path", default=None, help="локальный CSV КР1.1 (пропустить скачивание)"
)
parser.add_argument(
"--kr12-path", default=None, help="локальный CSV КР1.2 (пропустить скачивание)"
)
parser.add_argument(
"--work-dir",
default=None,
help="каталог для скачанных/распакованных CSV (по умолчанию — временный, удаляется после)",
)
parser.add_argument(
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
)
parser.add_argument(
"--load-only",
action="store_true",
help="только скачать+распарсить+UPSERT staging, без backfill houses/propagate listings",
)
parser.add_argument(
"--backfill-only",
action="store_true",
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
"или предыдущим прогоном) — только backfill houses + propagate listings",
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = build_parser()
args = parser.parse_args()
if args.load_only and args.backfill_only:
parser.error("--load-only и --backfill-only взаимоисключающи")
db = SessionLocal()
try:
load_counts: dict[str, int] = {}
if not args.backfill_only:
load_counts = load_domrf_kapremont(
db,
kr11_path=args.kr11_path,
kr12_path=args.kr12_path,
work_dir=args.work_dir,
dry_run=args.dry_run,
)
if not args.dry_run:
db.commit()
logger.info("domrf_kapremont_load: load stage done: %s", load_counts)
houses_counts: dict[str, int] = {}
listings_counts: dict[str, int] = {}
if not args.load_only:
houses_counts = backfill_houses_from_domrf(db, dry_run=args.dry_run)
listings_counts = propagate_listings_year_from_houses(db, dry_run=args.dry_run)
if not args.dry_run:
db.commit()
logger.info(
"domrf_kapremont_load DONE: dry_run=%s load=%s houses=%s listings=%s",
args.dry_run,
load_counts,
houses_counts,
listings_counts,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,196 @@
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
публикации так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
Порог алерта (документирование выбора):
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
Монитор: age > sber_index_max_age_days + lag_allowance.
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) запас на ИНХЕРЕНТНЫЙ лаг
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month лейбл
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» алерта нет
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 alert=1.
Задача синхронная (DB-only, один SELECT max(period_month)) запускается
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) WARNING
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
оценивать).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, date, datetime
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_LAG_ALLOWANCE_DAYS",
"SberFreshnessVerdict",
"check_sber_freshness",
"evaluate_sber_freshness",
]
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
DEFAULT_LAG_ALLOWANCE_DAYS = 25
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
# sber_price_index для областного уровня.
SBER_MONITOR_CITY = "Свердловская область"
_LATEST_SBER_PERIOD_SQL = text("""
SELECT max(period_month) AS latest
FROM sber_price_index
WHERE city = CAST(:city AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
-- estimator._load_sber_index_series.
AND (segment IS NULL OR segment ILIKE '%вторичн%')
""")
@dataclass(frozen=True)
class SberFreshnessVerdict:
"""Вердикт свежести СберИндекса по max(period_month)."""
latest_period: date
age_days: int
stale: bool
def evaluate_sber_freshness(
latest_period: date,
now: datetime,
max_age_days: int,
) -> SberFreshnessVerdict:
"""Чистая логика: устарел ли latest период СберИндекса.
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
`max_age_days` ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
"""
age_days = (now.date() - latest_period).days
stale = age_days > max_age_days
return SberFreshnessVerdict(
latest_period=latest_period,
age_days=age_days,
stale=stale,
)
def check_sber_freshness(
db: Session,
run_id: int,
params: dict | None = None, # type: ignore[type-arg]
now: datetime | None = None,
) -> dict[str, int]:
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
и финализирует run.
Params (default_params jsonb):
lag_allowance_days: int запас сверх sber_index_max_age_days (default 25).
`now` инъектируется в тестах (frozen); в проде None datetime.now(UTC).
Returns counters {latest_year, latest_month, age_days, alert}.
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
при алерте прогон помечается done (это монитор, не сбой джобы).
"""
params = params or {}
now = now or datetime.now(UTC)
counters: dict[str, int] = {
"latest_year": 0,
"latest_month": 0,
"age_days": 0,
"alert": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
latest: date | None = row.latest if row is not None else None
if latest is None:
logger.warning(
"sber freshness: sber_price_index пуст/недоступен для region=%s "
"(вторичка) — оценить свежесть нельзя",
SBER_MONITOR_CITY,
)
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
return counters
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
max_age_days = settings.sber_index_max_age_days + lag_days
verdict = evaluate_sber_freshness(latest, now, max_age_days)
counters = {
"latest_year": latest.year,
"latest_month": latest.month,
"age_days": verdict.age_days,
"alert": int(verdict.stale),
}
if verdict.stale:
logger.warning(
"sber freshness: max(period_month)=%s устарел на %d дней "
"(> порога %d = sber_index_max_age_days %d + lag %d); "
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
"проверь sber_index_pull и доступность новых периодов источника",
latest,
verdict.age_days,
max_age_days,
settings.sber_index_max_age_days,
lag_days,
)
else:
logger.info(
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
"region=%s — алерта нет",
latest,
verdict.age_days,
max_age_days,
SBER_MONITOR_CITY,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
run_id,
latest,
counters["alert"],
counters["age_days"],
)
return counters
except Exception as exc:
logger.exception("check_sber_freshness run_id=%d failed", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -22,6 +22,19 @@
--
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
--
-- LOW audit #4 — EKB scope (doc-only, no behavior change): this seed and its
-- `premium_houses` MV fallback (data/sql/139) are both curated/calibrated against
-- Ekaterinburg secondary-market listings ONLY. Non-EKB descriptions encountered
-- during curation were treated as noise and actively DEMOTED (is_premium=false) —
-- e.g. house_id=372944 below ("г. Нижний Новгород, Александровская слобода... это
-- НЕ ЕКБ" — mislabeled, not premium). Consequence: an oblast (non-EKB) elite
-- building will NOT receive the `premium_building` flag from this table or its MV
-- fallback — the estimator's `_is_premium_building` (app/services/estimator.py)
-- has no per-city variant of this overlay. This is not a silent gap: the estimator's
-- `_manual_review` still routes genuinely high-value objects to manual review via
-- absolute-value thresholds (estimate_manual_review_high_value_rub,
-- estimate_manual_review_elite_ppm2) independent of the premium_building flag.
BEGIN;

View file

@ -0,0 +1,67 @@
-- 176_domrf_kapremont.sql
--
-- CONTEXT: issue #2013 — houses.year_built заполнен только на 38%, houses.material_walls
-- на 0%. Существующий ГИС-ЖКХ loader (app/tasks/zhkh_flats_load.py, мигр. 146/149)
-- заполняет houses.zhkh_year (70% = 5002/7128) и houses.zhkh_floors (70%), НО НИКОГДА
-- не копирует их в houses.year_built/total_floors — это основной пробел. material_walls
-- вообще ни разу не заполнялся никаким источником (0%). Estimator
-- (app/services/estimator.py) фильтрует когорту по `year_built BETWEEN ...` — реальные
-- годы напрямую двигают точность оценки. Это REAL fix для #2013 (взамен запаркованного
-- когорт-исключения PR #2474).
--
-- Источник: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
-- КР1.1 house registry (export/190) — zip → CSV. mkd_code, houseguid (ФИАС GUID дома),
-- address, commission_year (год ввода, int), total_sq, number_floors_max.
-- КР1.2 constructive elements (export/275) — zip → CSV, LONG FORMAT (строка на
-- конструктивный элемент на mkd_code); wall_material заполнен только на строке
-- элемента «фасад».
-- listings→houses линковка через listings.house_id_fk (avito 52.5% linked, year_built
-- 0% на avito → backfill поднимет до ~36-52% через houses.year_built propagation).
--
-- WHAT:
-- Таблица domrf_kapremont — staging-приёмник КР1.1+КР1.2 (region 66), ключ mkd_code.
-- Заполняется отдельным loader'ом (app/services/domrf_kapremont_loader.py,
-- app/tasks/domrf_kapremont_load.py). Backfill houses.year_built/material_walls/
-- total_floors и propagation listings.year_built — отдельный код-путь той же таски,
-- НЕ в этой миграции (только схема здесь, никакого DML над houses/listings).
--
-- DEPENDENCIES (existing prod objects): houses (gar_house_guid, house_fias_id,
-- zhkh_house_guid, zhkh_year, zhkh_floors, year_built, material_walls, total_floors —
-- все уже существуют: мигр. 009/010/070/146/149/152), listings (house_id_fk, year_built —
-- мигр. 002/130).
--
-- SAFETY / IDEMPOTENCY: только CREATE TABLE/INDEX IF NOT EXISTS + COMMENT. Повторный
-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ
-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 143/149.
--
-- NB по нумерации: последний занятый = 175 (ls data/sql | grep '^17[0-9]'); следующий
-- свободный sequential = 176.
--
-- Deploy order: после 175_scrape_schedules_seed_domclick_detail_backfill.sql.
BEGIN;
CREATE TABLE IF NOT EXISTS domrf_kapremont (
mkd_code text PRIMARY KEY, -- ДОМ.РФ код МКД (КР1.1/КР1.2 join-ключ)
houseguid text, -- ФИАС GUID дома (КР1.1 houseguid)
address text,
commission_year smallint, -- год ввода в эксплуатацию (КР1.1 commission_year)
number_floors_max int, -- этажность (КР1.1 number_floors_max)
total_sq numeric(10, 2), -- общая площадь дома, м² (КР1.1 total_sq)
wall_material text, -- материал стен (КР1.2, строка «фасад»; wall_material)
loaded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS domrf_kapremont_houseguid_idx
ON domrf_kapremont (houseguid)
WHERE houseguid IS NOT NULL;
COMMENT ON TABLE domrf_kapremont IS
'ДОМ.РФ капремонт open data (КР1.1 house registry export/190 + КР1.2 constructive '
'elements export/275), region 66 (Свердловская обл.). Ключ mkd_code. houseguid — ФИАС '
'GUID дома, матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
'zhkh_house_guid) (app/services/domrf_kapremont_loader.py). wall_material берётся из '
'КР1.2 со строки конструктивного элемента «фасад» (единственная строка на mkd_code, '
'где это поле непустое). Заполняется app/tasks/domrf_kapremont_load.py (issue #2013).';
COMMIT;

View file

@ -0,0 +1,51 @@
-- 177_deals_city_region.sql
-- deals.city + region_code backfill — расширение Mera trade-in estimator с
-- Екатеринбурга на ВСЮ Свердловскую область (region_code=66, все города).
--
-- WHY:
-- Ночной import_rosreestr_dkp (scheduler.py, source='rosreestr_dkp_import')
-- до сих пор фильтровал `city ILIKE '%катеринбург%'` и хардкодил
-- 'Екатеринбург, ' в address, а deals.region_code никогда не заполнялся
-- (NULL на всех 49791 текущих строках). Foreign table
-- gendesign_rosreestr_deals уже отдаёт чистые city + region_code из
-- source-таблицы (migration 072) — снятие фильтра открывает +47183
-- не-ЕКБ сделок, уже сидящих в источнике.
--
-- WHAT:
-- - deals.region_code уже существует (002_core_tables.sql), но deals.city
-- отсутствовал (было только текстовое поле address с хардкод-префиксом
-- 'Екатеринбург, '). Добавляем city text — импортёр (scheduler.py,
-- тот же PR) теперь заполняет его из реального source.city.
-- - Индекс (city, deal_date) — под будущие per-city аналитические запросы
-- (аналог deals_source_idx для (source, deal_date)).
-- - Backfill существующих 49791 строк: все текущие deals с source='rosreestr'
-- импортированы ДО этой миграции старым EKB-only кодом, т.е. фактически
-- все они — Екатеринбург (то же допущение, что зашито в address
-- 'Екатеринбург, ' + street). Простановка region_code=66, city='Екатеринбург'
-- задним числом делает старые строки self-describing наравне с новыми.
--
-- SCOPE: только schema (city column + index) + backfill старых rosreestr-строк.
-- Новые импорты (после деплоя scheduler.py) заполняют оба поля сами.
--
-- IDEMPOTENCY / SAFETY:
-- - ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS — безопасный re-run.
-- - Backfill UPDATE ограничен `region_code IS NULL OR city IS NULL` —
-- повторный прогон no-op (после первого раза условие уже false).
--
-- Dependencies: 002_core_tables.sql (deals, deals.region_code)
BEGIN;
ALTER TABLE deals ADD COLUMN IF NOT EXISTS city text;
CREATE INDEX IF NOT EXISTS deals_city_deal_date_idx ON deals (city, deal_date);
-- Backfill: все существующие rosreestr-сделки импортированы старым EKB-only
-- кодом (до этой миграции) => гарантированно Екатеринбург, region_code=66.
UPDATE deals
SET region_code = 66,
city = 'Екатеринбург'
WHERE source = 'rosreestr'
AND (region_code IS NULL OR city IS NULL);
COMMIT;

View file

@ -0,0 +1,87 @@
-- 178_deal_city_price_bands.sql
-- Per-city ppm² plausible-deal guard-bands — снятие Екатеринбург-центричного
-- глобального DEAL_MIN_PPM2=50_000 (estimator.py) для остальной Свердловской обл.
--
-- WHY:
-- Миграция 177 открыла +47183 не-ЕКБ сделок (deals.city заполнен для всей
-- области). Но estimator.py всё ещё режет ДКП-сделки абсолютным guard-band
-- [DEAL_MIN_PPM2=50_000, DEAL_MAX_PPM2=800_000] ₽/м² — калиброванным
-- ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку (см. комментарий у DEAL_MIN_PPM2 в
-- estimator.py). Для дешёвых городов области этот пол — не anti-outlier
-- guard, а cut-off легитимного рынка:
-- Североуральск median ≈ 21.7k ₽/м², Новоуральск ≈ 36k, Асбест ≈ 41k —
-- ВСЕ ниже 50k → 46.6% не-ЕКБ сделок молча дропаются как «доля/ошибка»,
-- хотя это нормальная цена для города.
--
-- WHAT:
-- Таблица deal_city_price_bands — per-city [ppm2_min, ppm2_max] band,
-- вычисленный из РЕАЛЬНЫХ rosreestr-сделок города (p1/p99 персентиль,
-- зажатый абсолютными hard-floor/ceiling 8000/800000 — доли и опечатки
-- режутся даже в самых дешёвых городах). Екатеринбург НАМЕРЕННО исключён
-- из популяции — estimator.py при отсутствии строки в этой таблице
-- fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 константы, т.е.
-- ЕКБ-поведение byte-identical сегодняшнему (см. invariant в PR).
--
-- Города с < 30 сделок исключены (HAVING count(*) >= 30) — слишком тонкая
-- выборка для устойчивого перцентиля, estimator fallback'ит на global band.
--
-- USAGE (estimator.py, тот же PR):
-- - SQL enforcement (_fetch_dkp_corridor, ~line 1263): LEFT JOIN на
-- deals.city = deal_city_price_bands.city, COALESCE(b.ppm2_min/max, global).
-- - Python enforcement (_is_plausible_deal via _fetch_deals): bands dict,
-- загружен один раз через _load_city_price_bands(db), .get(city, global).
--
-- IDEMPOTENCY / SAFETY:
-- CREATE TABLE IF NOT EXISTS — безопасный re-run. Populate — INSERT ...
-- ON CONFLICT (city) DO UPDATE, т.е. повторный прогон РЕФРЕШИТ бэнды под
-- свежие сделки (не no-op по дизайну — таблица предполагает периодический
-- refresh по мере роста deals; scheduler для авто-рефреша НЕ входит в этот
-- PR, см. follow-up в PR description).
--
-- Dependencies: 177_deals_city_region.sql (deals.city, backfill).
BEGIN;
CREATE TABLE IF NOT EXISTS deal_city_price_bands (
city text PRIMARY KEY,
ppm2_min int NOT NULL,
ppm2_max int NOT NULL,
n_deals int NOT NULL,
refreshed_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE deal_city_price_bands IS
'Per-city ppm² plausible-deal guard-bands (estimator.py _is_plausible_deal / '
'_fetch_dkp_corridor). Екатеринбург НЕ включён — estimator fallback на глобальные '
'DEAL_MIN_PPM2=50000/DEAL_MAX_PPM2=800000 сохраняет ЕКБ-поведение byte-identical. '
'Refresh: повторный прогон этого файла (ON CONFLICT DO UPDATE); авто-scheduler '
'пока не реализован (см. issue follow-up).';
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, refreshed_at)
SELECT
city,
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
AS ppm2_min,
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
AS ppm2_max,
count(*) AS n_deals,
now() AS refreshed_at
FROM deals
WHERE source = 'rosreestr'
AND price_per_m2 IS NOT NULL
AND city IS NOT NULL
AND city <> 'Екатеринбург'
GROUP BY city
HAVING count(*) >= 30
-- Инвариант ppm2_max >= ppm2_min: если весь распределение города < 8000 ₽/м²
-- (p99 < 8000), LEAST(p99,800000) < GREATEST(p1,8000) → band [8000, <8000] не
-- матчит ничего → все сделки города молча дропались бы. Гейтим такие города
-- (для жилой ДКП недостижимо, но airtight против мусорного города).
AND round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int >= 8000
ON CONFLICT (city) DO UPDATE
SET ppm2_min = EXCLUDED.ppm2_min,
ppm2_max = EXCLUDED.ppm2_max,
n_deals = EXCLUDED.n_deals,
refreshed_at = EXCLUDED.refreshed_at;
COMMIT;

View file

@ -0,0 +1,199 @@
-- 179_scrape_schedules_seed_oblast_city_sweeps.sql
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout:
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области.
-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит.
--
-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false.
-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies,
-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его.
-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors,
-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и
-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в
-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы
-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз:
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil';
--
-- WHY отдельный source на город (а не один source + params.city со списком городов):
-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с
-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source
-- либо конфликтует, либо перезаписывает первую строку — по факту только один город
-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source =
-- '<source_type>_<city_slug>' (например 'cian_city_sweep_kamensk_uralskiy'), а
-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же
-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п.,
-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает
-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS.
--
-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": "<slug>"}.
-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции —
-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на
-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед
-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский).
--
-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь
-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) —
-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные
-- sweep'ы разных источников делили бы тот же прокси).
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов,
-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_<slug> != city_sweep).
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
-- ── avito_city_sweep_<city> (база: radius_m 1500, detail_top_n 20, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ──────────
(
'avito_city_sweep_nizhniy_tagil',
false,
0,
1,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb
),
(
'avito_city_sweep_kamensk_uralskiy',
false,
1,
2,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb
),
(
'avito_city_sweep_pervouralsk',
false,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb
),
(
'avito_city_sweep_verkhnyaya_pyshma',
false,
7,
8,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'avito_city_sweep_serov',
false,
8,
9,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb
),
-- ── cian_city_sweep_<city> (база: radius_m 1500, detail_top_n 10, enrich_houses true,
-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ─────────
(
'cian_city_sweep_nizhniy_tagil',
false,
9,
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb
),
(
'cian_city_sweep_kamensk_uralskiy',
false,
10,
11,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb
),
(
'cian_city_sweep_pervouralsk',
false,
11,
12,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb
),
(
'cian_city_sweep_verkhnyaya_pyshma',
false,
12,
13,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'cian_city_sweep_serov',
false,
13,
14,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb
),
-- ── yandex_city_sweep_<city> (база: radius_m 25000, pages_per_anchor 3,
-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok —
-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не
-- query-ограничение, лишний охват не увеличивает load) ────────────────────────
(
'yandex_city_sweep_nizhniy_tagil',
false,
14,
15,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb
),
(
'yandex_city_sweep_kamensk_uralskiy',
false,
15,
16,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb
),
(
'yandex_city_sweep_pervouralsk',
false,
17,
18,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb
),
(
'yandex_city_sweep_verkhnyaya_pyshma',
false,
18,
19,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb
),
(
'yandex_city_sweep_serov',
false,
19,
20,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMENT ON TABLE scrape_schedules IS
'In-app scheduler config (заменяет cron-script setup). '
'Sources: avito_city_sweep, yandex_city_sweep, cian_city_sweep (EKB, enabled), '
'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, '
'avito_city_sweep_<city>/cian_city_sweep_<city>/yandex_city_sweep_<city> '
'(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/'
'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), '
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, '
'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, '
'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, '
'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, '
'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, '
'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, '
'deactivate_stale_avito/yandex/cian.';
COMMIT;

View file

@ -0,0 +1,75 @@
-- 180_seed_sber_freshness_monitor.sql
-- Audit п.1 — seed scrape_schedules row for the daily СберИндекс data-freshness monitor.
--
-- Context (verified on prod 2026-07-12):
-- estimator._load_sber_index_series (#794/#audit-5a) applies a СберИндекс time-adjustment
-- to ДКП deals and only LOGS a per-estimate warning when the latest series month is older
-- than settings.sber_index_max_age_days (35d). The `sber_index_pull` job runs monthly
-- (enabled), but СберИндекс publishes with an inherent ~1-2 month lag, so
-- sber_price_index.period_month drifts. Current max(period_month) for the oblast secondary
-- segment = 2026-05-01 (~72d old). NOT a silent failure, but the staleness is buried in
-- per-estimate warnings — invisible at monitor cadence.
--
-- Audit gap: ops has no monitor-frequency signal for СберИндекс staleness. This monitor
-- watches max(period_month) directly and raises a per-day WARNING when the data is stale
-- beyond the allowed publication lag.
--
-- Staleness threshold (app/tasks/sber_freshness_monitor.py::evaluate_sber_freshness):
-- age_days = now() - max(period_month)
-- max_age_days = settings.sber_index_max_age_days (35) + lag_allowance_days (25) = 60
-- stale = age_days > max_age_days
-- The +25 lag_allowance covers the INHERENT СберИндекс publication lag (source is 1-2mo
-- behind; period_month is the first-of-month label; the monthly pull has not yet caught the
-- newest period). Below 60d the latest period is treated as normally-lagging -> NO alert
-- (avoids daily noise on штатный lag). Above 60d the data is stuck beyond ~2 months -> alert.
-- At 2026-07-12: max=2026-05-01, age=72d > 60 -> alert=1 (surfaces the current drift).
--
-- Components deployed together:
-- 1. app/tasks/sber_freshness_monitor.py — check_sber_freshness() + evaluate_sber_freshness()
-- 2. app/services/product_handlers.py — kit Handler 'sber_freshness_monitor'
-- 3. This migration — seeds the scrape_schedules row (enabled=true, daily window)
--
-- Schedule window 09:00-10:00 UTC:
-- Daily cadence, placed AFTER deals_freshness_monitor (08:00-09:00 UTC) so the two
-- data-freshness monitors do not overlap. Cheap: one SELECT max(period_month), no external
-- HTTP — safe to run daily even though sber_index_pull itself is monthly.
--
-- default_params.lag_allowance_days = 25:
-- Allowance for СберИндекс publication lag on top of the 35d per-estimate guard (-> 60d).
--
-- next_run_at bootstrapped to tomorrow 09:00 UTC — scheduler will not fire immediately
-- on deploy (same pattern as 162/093/096/160).
--
-- Idempotent: ON CONFLICT (source) DO NOTHING — safe to re-apply.
--
-- Dependencies:
-- 052_scrape_schedules.sql (table + UNIQUE(source))
-- sber_price_index table (populated by app.tasks.sber_index_pull)
-- app/services/product_handlers.py + tasks/sber_freshness_monitor.py deployed
--
-- Deploy order:
-- Apply after deploying the product_handlers.py + task changes so the dispatch can resolve
-- 'sber_freshness_monitor' correctly on first fire.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'sber_freshness_monitor',
true, -- SAFE: single SELECT max(period_month), no ext calls
9, -- window 09:00-10:00 UTC (after deals_freshness_monitor)
10,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
'{"lag_allowance_days": 25}'::jsonb -- publication-lag allowance over the 35d per-estimate guard
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,16 @@
-- 181_clamp_bad_listing_dates.sql
-- LOW audit R2 (#6): null out implausible listing_date/publish_date left by scrapers
-- before the clamp guard landed (Avito 1970-epoch: ~698 rows; near-future: ~79 rows).
-- Idempotent: re-running is a no-op once values are already NULL / in-window.
BEGIN;
UPDATE listings SET listing_date = NULL
WHERE listing_date IS NOT NULL
AND (listing_date < DATE '2010-01-01' OR listing_date > CURRENT_DATE + 2);
UPDATE listings SET publish_date = NULL
WHERE publish_date IS NOT NULL
AND (publish_date < DATE '2010-01-01' OR publish_date > CURRENT_DATE + 2);
COMMIT;

View file

@ -0,0 +1,43 @@
-- 182_trade_in_leads_consent_proof.sql
-- LOW audit R2 (#9): durable 152-ФЗ proof-of-consent for trade_in_leads.
--
-- WHY:
-- #2497 already computes client_ip (X-Forwarded-For, fallback direct peer)
-- and pins _CONSENT_POLICY_VERSION = "2026-07" in app/api/v1/lead.py, but
-- both were written ONLY to the audit log — trade_in_leads had no columns
-- to hold them (explicit TODO at the INSERT in lead.py). A bare boolean
-- `consent` column plus a log line is not durable proof: logs rotate/expire,
-- the row itself carries no evidence of what was agreed to, under what
-- policy version, or from what IP.
--
-- WHAT:
-- Three nullable columns on trade_in_leads:
-- - client_ip (inet) — same value already computed in
-- create_trade_in_lead, now persisted
-- per-row instead of log-only.
-- - consent_policy_version (text) — snapshot of _CONSENT_POLICY_VERSION
-- at the time this lead was captured.
-- - consent_text_snapshot (text) — snapshot of the exact consent sentence
-- shown to the user (_CONSENT_TEXT_SNAPSHOT
-- in lead.py, same PR).
--
-- IDEMPOTENCY / SAFETY:
-- ADD COLUMN IF NOT EXISTS x3 — safe re-run. All nullable, no DEFAULT, no
-- backfill: existing rows keep NULL (no consent proof was captured for them
-- pre-migration — that's an honest reflection of what actually happened,
-- not a schema gap). Purely additive: no existing reads/writes break.
--
-- Dependencies: 172_trade_in_leads.sql (trade_in_leads table).
BEGIN;
ALTER TABLE trade_in_leads
ADD COLUMN IF NOT EXISTS client_ip inet,
ADD COLUMN IF NOT EXISTS consent_policy_version text,
ADD COLUMN IF NOT EXISTS consent_text_snapshot text;
COMMENT ON COLUMN trade_in_leads.client_ip IS '152-ФЗ proof-of-consent: клиентский IP на момент согласия (ранее только в audit-логе, #2497 TODO).';
COMMENT ON COLUMN trade_in_leads.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _CONSENT_POLICY_VERSION на момент согласия.';
COMMENT ON COLUMN trade_in_leads.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия, показанного пользователю (_CONSENT_TEXT_SNAPSHOT).';
COMMIT;

View file

@ -0,0 +1,28 @@
-- 183_reenable_deactivate_stale_domklik.sql
--
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user, 2026-07-13): ВКЛЮЧАЕМ обратно TTL-деактивацию
-- deactivate_stale_domklik (была отключена миграцией 163 «до возобновления
-- регулярных domclick-свипов»).
--
-- Условие из 163 снято: свипы domclick_city_sweep + domclick_detail_backfill
-- возобновлены и стабильно бегают ежедневно (scraped_at свежий, диапазон
-- последней недели), т.е. причина отключения больше не действует.
--
-- Безопасность re-enable (LOW audit R2 #10):
-- • Джоб ключуется на scraped_at (whitelist last_seen_at/scraped_at в
-- deactivate_stale_avito.py::_ALLOWED_STALENESS_COLUMNS), НЕ на listing_date —
-- исходная гипотеза «выпадет весь domklik по NULL listing_date» неприменима.
-- • При живых свипах scraped_at < NOW()-14d только у реально протухших строк,
-- а не у всего пула (в отличие от ситуации 163, где свипы стояли) —
-- деактивация корректна, а не тотальна. Чинит завышенный freshness domklik.
--
-- Идемпотентно: повторный прогон — no-op (enabled уже true). Row создаётся
-- миграцией 160; если отсутствует — UPDATE затронет 0 строк (безопасно).
BEGIN;
UPDATE scrape_schedules
SET enabled = true
WHERE source = 'deactivate_stale_domklik';
COMMIT;

View file

@ -0,0 +1,60 @@
-- 184_user_events.sql
-- Foundation schema for Features 2 & 3: unified user-event tracking.
--
-- WHY:
-- trade-in had no durable event log for login/IP audit or behavior
-- analytics. A near-identical design already existed once — `audit_log`
-- (event_type, ip_address inet, user_agent, estimate_id uuid, payload
-- jsonb, created_at) was defined in 002_core_tables.sql and DROPPED in
-- 095_dead_schema.sql as unused dead schema at the time. Product now
-- needs exactly that shape again, so this migration resurrects the
-- design under a new name, `user_events`, as the ONE unified
-- append-only table serving:
-- - login-audit (who logged in, from what IP/UA, when)
-- - search-audit (estimate requests, listing lookups)
-- - behavior-analytics (pdf_download, listing_click, page_view,
-- drawer_open, and future event_type values)
--
-- WHAT:
-- `user_events` — append-only, admin-read-only. No 152-ФЗ consent
-- gating and no mandatory retention policy for MVP (product-owner
-- decision): we log IP/UA/path/method/payload unconditionally for every
-- tracked event. `estimate_id` is a plain uuid column with NO FK
-- constraint on purpose — this keeps the log decoupled/append-only so
-- estimate deletion (or any future estimate lifecycle change) never
-- blocks or cascades into event rows.
--
-- IDEMPOTENCY / SAFETY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
-- safe re-run. Purely additive: no existing table/view/column is
-- touched.
--
-- Dependencies: none (new standalone table). Auto-applied on deploy via
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
BEGIN;
CREATE TABLE IF NOT EXISTS user_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_type text NOT NULL,
username text NOT NULL,
ip_address inet,
user_agent text,
path text,
method text,
estimate_id uuid,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE user_events IS 'Unified append-only event log (login/IP audit + behavior analytics), admin-read-only. Resurrects the design of audit_log (002_core_tables.sql, dropped in 095_dead_schema.sql).';
COMMENT ON COLUMN user_events.event_type IS 'e.g. login / estimate_request / pdf_download / listing_click / page_view / drawer_open.';
COMMENT ON COLUMN user_events.username IS 'X-Authenticated-User value at the time of the event.';
COMMENT ON COLUMN user_events.estimate_id IS 'No FK constraint by design — keeps the log decoupled/append-only from trade_in_estimates lifecycle.';
CREATE INDEX IF NOT EXISTS user_events_username_created_at_idx ON user_events (username, created_at DESC);
CREATE INDEX IF NOT EXISTS user_events_event_type_created_at_idx ON user_events (event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS user_events_ip_address_idx ON user_events (ip_address);
CREATE INDEX IF NOT EXISTS user_events_created_at_idx ON user_events (created_at DESC);
COMMIT;

View file

@ -0,0 +1,59 @@
-- Migration 185: account_quota_overrides — персональные месячные лимиты оценок
--
-- WHY:
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
-- `UPDATE account_estimate_usage SET used = used - N` (negative used). Для user2
-- (Брусника) это дало used=-35 при глобальном limit=15 → GET /quota отдавал
-- {limit:15, used:-35, remaining:50} → фронт (EstimateForm.tsx) рендерил абсурд
-- «Осталось 50 из 15». Правильное решение — персональный лимит, а не отрицательный
-- счётчик использования.
--
-- WHAT:
-- 1. Таблица account_quota_overrides(username PK, monthly_limit, note, updated_at) —
-- per-user override глобального settings.estimate_quota_limit. Читается через
-- app.services.account_quota.user_limit().
-- 2. Seed: user2 (Брусника) → monthly_limit=50, пилотный грант 2026-07-13.
-- 3. Сброс хака: account_estimate_usage.used < 0 → 0 (для user2, единственного
-- затронутого аккаунта). После этой миграции user2: limit=50, used=0,
-- remaining=50 — корректно вместо «50 из 15».
--
-- IDEMPOTENCY:
-- CREATE TABLE IF NOT EXISTS + INSERT ... ON CONFLICT DO UPDATE (upsert, безопасен
-- при повторном применении) + UPDATE ... WHERE used < 0 (после первого прогона
-- used=0, условие больше не матчит — безопасный no-op при ре-apply).
--
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage).
BEGIN;
CREATE TABLE IF NOT EXISTS account_quota_overrides (
username text PRIMARY KEY,
monthly_limit integer NOT NULL,
note text,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE account_quota_overrides IS
'Персональный override месячного лимита оценок trade-in (вместо глобального '
'settings.estimate_quota_limit). Читается app.services.account_quota.user_limit().';
COMMENT ON COLUMN account_quota_overrides.monthly_limit IS
'Персональный лимит успешных оценок в календарный месяц для username.';
COMMENT ON COLUMN account_quota_overrides.note IS
'Причина override (grant / pilot / manual bump) — для аудита ручных изменений.';
INSERT INTO account_quota_overrides (username, monthly_limit, note)
VALUES ('user2', 50, 'Брусника — пилот, грант 2026-07-13')
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
note = EXCLUDED.note,
updated_at = now();
-- Сбрасываем прежний хак бонусных попыток (negative used), который ломал /quota:
-- limit=15, used=-35 → remaining=50 → фронт рендерил «Осталось 50 из 15».
UPDATE account_estimate_usage
SET used = 0, updated_at = now()
WHERE username = 'user2' AND used < 0;
COMMIT;

View file

@ -86,6 +86,9 @@ USAGE
# machine-readable:
python -m scripts.backtest_estimator --json
# oblast D: per-city validation (exact deals.city name, not a slug):
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
"""
from __future__ import annotations
@ -181,6 +184,14 @@ logger = logging.getLogger("backtest_estimator")
# Price-per-m² sanity band — shared by the deal sample and the listings
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
# obvious data-entry garbage / commercial outliers.
# (oblast D) When --city is set, _load_sample sources a PER-CITY band from
# deal_city_price_bands instead (see _resolve_city_ppm2_band) — these globals
# remain the fallback (default/unscoped sample, and any city with no band row,
# e.g. Екатеринбург — intentionally excluded from that table).
# NB: the listings subquery (_CANDIDATES_SQL, asking-core engine only) still
# always uses these globals — left global deliberately (not city-scoped) to
# keep the change minimal; the asking-core engine is the legacy comparison
# path, not the primary oblast-D target (the full engine, default).
PPM2_MIN = 30_000
PPM2_MAX = 600_000
@ -1043,22 +1054,115 @@ _CANDIDATES_SQL = text(
"""
)
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
# never issues this query.
_CITY_PPM2_BAND_SQL = text(
"""
SELECT ppm2_min, ppm2_max
FROM deal_city_price_bands
WHERE city = CAST(:city AS text)
"""
)
def _load_sample(db: Session, *, sample: int, since: str) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]."""
rows = (
db.execute(
_SAMPLE_SQL,
{
"ppm2_min": PPM2_MIN,
"ppm2_max": PPM2_MAX,
"since": since,
"sample": sample,
},
)
.mappings()
.all()
def _sample_sql(city: str | None) -> Any:
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
before this change literal identity, not just equal text so the
default CLI invocation (and the frozen EKB regression gate, which never
calls this path at all) see byte-identical SQL.
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
scopes the sample to that ``deals.city`` value populated oblast-wide by
the #C1 migration (368 cities, 96,974 deals). Pass the EXACT canonical
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
slug see the ``--city`` CLI help for the naming decision.
"""
if city is None:
return _SAMPLE_SQL
return text(
"""
SELECT
id,
ST_X(geom::geometry) AS lon,
ST_Y(geom::geometry) AS lat,
rooms,
price_per_m2 AS sold_ppm2,
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 id DESC
LIMIT CAST(:sample AS integer)
"""
)
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
``city is None`` ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
materially different sane / range than the EKB-tuned 30k..600k globals
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
Екатеринбург intentionally excluded from the table, mirrors
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
the globals; read-only best-effort, never raises.
"""
if city is None:
return float(PPM2_MIN), float(PPM2_MAX)
try:
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
return float(PPM2_MIN), float(PPM2_MAX)
if row is None or row["ppm2_min"] is None or row["ppm2_max"] is None:
return float(PPM2_MIN), float(PPM2_MAX)
return float(row["ppm2_min"]), float(row["ppm2_max"])
def _load_sample(
db: Session, *, sample: int, since: str, city: str | None = None
) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
value via ``_sample_sql`` and sources the PPM2 sanity band from
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
falls back to the module globals). Default None is byte-identical to the
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
no extra query.
"""
if city is None:
ppm2_min: float = PPM2_MIN
ppm2_max: float = PPM2_MAX
else:
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
params: dict[str, Any] = {
"ppm2_min": ppm2_min,
"ppm2_max": ppm2_max,
"since": since,
"sample": sample,
}
if city is not None:
params["city"] = city
rows = db.execute(_sample_sql(city), params).mappings().all()
out: list[DealSample] = []
for r in rows:
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
@ -1516,11 +1620,22 @@ def _predict_full_spine(
)
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
# (oblast C2 parity): estimate_quality now resolves a target city and passes
# it to _fetch_dkp_corridor (estimator.py:3210-3220) — deals.city is
# populated oblast-wide (368 cities), and an unscoped corridor lets
# same-named streets in OTHER region-66 towns (e.g. "Ленина" exists in many)
# contaminate the corridor. Mirror that resolve+pass here UNCONDITIONALLY
# (independent of --city, which only scopes WHICH deals get sampled) so this
# harness measures the SAME corridor prod actually computes today, not the
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
target_city = m._resolve_target_city(deal.address)
dkp_raw = m._fetch_dkp_corridor(
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
)
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
# → ([], None) instead of an unconditional fetch.
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
# estimate_quality — no-area / no-address → ([], None) instead of an
# unconditional fetch.
if deal.area_m2 and deal.address:
# NB(#2002): kept at None deliberately — the deliverable scopes the
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
@ -1733,6 +1848,7 @@ def run_backtest(
radius: int,
rooms_tolerance: int,
holdout_split: bool = False,
city: str | None = None,
) -> dict[str, Any]:
"""Drive the full read-only backtest and return a metrics dict.
@ -1747,9 +1863,13 @@ def run_backtest(
so its bias is near-zero by construction it proves the MECHANISM, not
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
to fit on even-id deals and evaluate on the odd-id half for an honest number.
``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour).
"""
deals = _load_sample(db, sample=sample, since=since)
logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since)
deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
matched_rows: list[tuple[float, float, int]] = []
matched_ids: list[int] = []
@ -1809,6 +1929,7 @@ def run_backtest(
"n_matched": len(matched_rows),
"n_no_analogs": n_no_analogs,
"holdout_split": holdout_split,
"city": city,
}
return metrics
@ -1820,6 +1941,7 @@ def run_backtest_full(
since: str,
dump_fixture: str | None = None,
resolve_house_id: bool = False,
city: str | None = None,
) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
@ -1844,10 +1966,18 @@ def run_backtest_full(
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output.
``city`` (oblast D, default None) scopes the deal sample to one
``deals.city`` value see ``_load_sample``. Default None is unscoped
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
``_fetch_dkp_corridor`` (oblast C2 parity fix) see its docstring.
"""
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since)
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since)
deals = _load_sample(db, sample=sample, since=since, city=city)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
)
predictions: list[Prediction] = []
n_no_prediction = 0
@ -1922,6 +2052,7 @@ def run_backtest_full(
"n_matched": len(predictions),
"n_no_prediction": n_no_prediction,
"price_segments_ppm2": [list(seg) for seg in _price_segments()],
"city": city,
}
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
@ -2011,6 +2142,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
default="2025-06-01",
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
)
p.add_argument(
"--city",
default=None,
metavar="NAME",
help="Oblast D: scope the held-out ДКП deal sample to ONE deals.city value "
"(region 66 is oblast-wide — deals.city covers 368 cities, 96,974 deals). "
"Pass the EXACT canonical Russian name as stored in deals.city, e.g. "
"'Нижний Тагил' or 'Каменск-Уральский' — NOT a slug/transliteration (run "
"`SELECT DISTINCT city FROM deals` to check the exact spelling). Also "
"sources the deal-sample PPM2 sanity band from deal_city_price_bands for "
"that city (falls back to the global PPM2_MIN/PPM2_MAX when the city has "
"no band row, e.g. Екатеринбург — intentionally excluded from that table). "
"Default None → unscoped sample, byte-identical to the pre-oblast-D SQL.",
)
p.add_argument(
"--radius",
type=int,
@ -2115,7 +2260,7 @@ def main(argv: list[str] | None = None) -> int:
logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s",
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
args.engine,
args.sample,
args.since,
@ -2124,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int:
args.holdout_split,
args.dump_fixture,
args.resolve_house_id,
args.city,
)
db = _session()
@ -2135,6 +2281,7 @@ def main(argv: list[str] | None = None) -> int:
since=args.since,
dump_fixture=args.dump_fixture,
resolve_house_id=args.resolve_house_id,
city=args.city,
)
else:
metrics = run_backtest(
@ -2144,6 +2291,7 @@ def main(argv: list[str] | None = None) -> int:
radius=args.radius,
rooms_tolerance=args.rooms_tolerance,
holdout_split=args.holdout_split,
city=args.city,
)
finally:
db.close()

View file

@ -7,16 +7,16 @@
"n_covered": 0
},
"low": {
"coverage_pct": 81.82,
"mape_pct": 13.23,
"n": 275,
"n_covered": 225
"coverage_pct": 81.88,
"mape_pct": 13.25,
"n": 276,
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"mape_pct": 14.64,
"n": 2,
"n_covered": 2
"mape_pct": 6.99,
"n": 1,
"n_covered": 1
}
},
"confidence_order": [
@ -136,14 +136,14 @@
"n_covered": 0
},
"low": {
"coverage_pct": 81.82,
"n": 275,
"n_covered": 225
"coverage_pct": 81.88,
"n": 276,
"n_covered": 226
},
"medium": {
"coverage_pct": 100.0,
"n": 2,
"n_covered": 2
"n": 1,
"n_covered": 1
}
}
},

View file

@ -0,0 +1,60 @@
"""LOW audit R2 (#6) — `_clamp_plausible_date` в `scraper_kit.base`.
Choke point для `listing_date`/`publish_date` в `save_listings()` (единый для
avito/cian/yandex/domclick). Проверено в БД: avito пишет 1970-01-01
(epoch-sentinel, ~698 строк) и даты до +11 дней в будущем (~79 строк); у
yandex единичные аномально старые годы. Правдоподобный диапазон
[2010-01-01, today+2d]; всё остальное None (колонка nullable).
Офлайн: без сети/curl_cffi/БД, только импорт + чистая функция.
"""
from __future__ import annotations
import os
from datetime import date, timedelta
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.base import _clamp_plausible_date
def test_avito_epoch_sentinel_rejected() -> None:
"""1970-01-01 — классический Avito 0-epoch sentinel."""
assert _clamp_plausible_date(date(1970, 1, 1)) is None
def test_just_before_min_plausible_rejected() -> None:
assert _clamp_plausible_date(date(2009, 12, 31)) is None
def test_far_future_rejected() -> None:
assert _clamp_plausible_date(date(2099, 1, 1)) is None
def test_none_passes_through_as_none() -> None:
assert _clamp_plausible_date(None) is None
def test_normal_recent_date_passes_through() -> None:
d = date(2024, 6, 1)
assert _clamp_plausible_date(d) == d
def test_today_passes() -> None:
today = date.today()
assert _clamp_plausible_date(today) == today
def test_today_plus_one_day_passes() -> None:
d = date.today() + timedelta(days=1)
assert _clamp_plausible_date(d) == d
def test_today_plus_three_days_rejected() -> None:
assert _clamp_plausible_date(date.today() + timedelta(days=3)) is None
def test_min_plausible_boundary_passes() -> None:
"""2010-01-01 сам по себе — граница включительно (в окне)."""
assert _clamp_plausible_date(date(2010, 1, 1)) == date(2010, 1, 1)

View file

@ -614,12 +614,12 @@ async def test_suggest_addresses_classifies_kind() -> None:
results = await dadata.suggest_addresses("test")
assert len(results) == 6
assert results[0].kind == "house" # 8
assert results[0].kind == "house" # 8
assert results[1].kind == "street" # 7
assert results[2].kind == "city" # 4
assert results[3].kind == "city" # 1
assert results[4].kind == "house" # 9 (>=8)
assert results[5].kind == "city" # missing → default
assert results[2].kind == "city" # 4
assert results[3].kind == "city" # 1
assert results[4].kind == "house" # 9 (>=8)
assert results[5].kind == "city" # missing → default
async def test_suggest_addresses_sends_proper_request() -> None:
@ -652,6 +652,55 @@ async def test_suggest_addresses_sends_proper_request() -> None:
assert captured["x_secret"] is None
async def test_suggest_addresses_region_body_shape_no_restrict_value() -> None:
"""region= constrains `locations` by region, WITHOUT a restrict_value key.
`restrict_value` is a top-level DaData body param (not a per-location dict
key) putting it inside the locations object would be a silent no-op.
The region constraint in `locations` is already a hard filter on its own.
"""
from app.services import dadata
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
captured["body"] = _json.loads(request.content.decode("utf-8"))
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
transport = httpx.MockTransport(handler)
with _patch_settings(token="my-token"), _patch_async_client(transport):
await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")
body = captured["body"]
assert isinstance(body, dict)
assert body["locations"] == [{"region": "Свердловская область"}]
assert "restrict_value" not in body["locations"][0]
assert "restrict_value" not in body
async def test_suggest_addresses_region_overrides_city() -> None:
"""Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
from app.services import dadata
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
captured["body"] = _json.loads(request.content.decode("utf-8"))
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
transport = httpx.MockTransport(handler)
with _patch_settings(), _patch_async_client(transport):
await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")
body = captured["body"]
assert isinstance(body, dict)
assert body["locations"] == [{"region": "Свердловская область"}]
async def test_suggest_addresses_clamps_limit() -> None:
"""count должен быть clamped в [1, 20] — DaData ограничение."""
from app.services import dadata
@ -671,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None:
body = captured["body"]
assert isinstance(body, dict)
assert body["count"] == 20 # clamped to max
# ─────────────────────────────────────────────────────────────────────────────
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
# ─────────────────────────────────────────────────────────────────────────────
import logging as _logging # noqa: E402
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
CLEAN_FEATURE_DISABLED_BODY = {
"timestamp": "2026-07-12T16:37:54.585+00:00",
"status": 403,
"error": "Forbidden",
"message": (
"Feature 'CLEAN' disabled for token 'xxx'. "
"See https://dadata.userecho.com/topics/7784 for help."
),
"path": "/api/v1/clean/address",
}
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
SUGGEST_HOUSE_PAYLOAD = {
"suggestions": [
{
"value": "г Екатеринбург, ул Малышева, д 4",
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
"data": {
"geo_lat": "56.831463",
"geo_lon": "60.580943",
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
"kladr_id": "6600000100006480017",
"qc_geo": "0",
"fias_level": "8",
"house": "4",
"street": "Малышева",
"city": "Екатеринбург",
},
}
]
}
def _mock_enrich_transport(
clean_status: int, clean_body: object, suggest_body: object
) -> httpx.MockTransport:
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if "cleaner.dadata.ru" in url:
return httpx.Response(clean_status, json=clean_body)
if "suggestions.dadata.ru" in url:
return httpx.Response(200, json=suggest_body)
return httpx.Response(404, json={"error": "unexpected url"})
return httpx.MockTransport(handler)
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
from app.services import dadata
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
text = caplog.text
assert "Стандартизация" in text or "выключена" in text
# Не должны обвинять токен при feature-disabled.
assert "auth/secret rejected" not in text
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
"""401 (или 403 без 'disabled') → сообщение про креды."""
from app.services import dadata
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
with _patch_settings(), _patch_async_client(transport):
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
result = await dadata.clean_address("Екатеринбург, Малышева 4")
assert result is None
assert "auth/secret rejected" in caplog.text
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
from app.services import dadata
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
with _patch_settings(), _patch_async_client(transport):
results = await dadata.suggest_addresses("Малышева 4")
assert len(results) == 1
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
assert results[0].kladr_id == "6600000100006480017"
assert results[0].qc_geo == 0
async def test_enrich_address_uses_clean_when_available() -> None:
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
from app.services import dadata
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
assert result is not None
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
assert result.house_cadnum == "66:41:0704045:350"
assert result.raw.get("_source") != "suggest_fallback"
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
from app.services import dadata
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
assert result is not None
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
assert result.kladr_id == "6600000100006480017"
assert result.qc_geo == 0
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
assert result.canonical_address is None
assert result.house_cadnum is None
assert result.raw.get("_source") == "suggest_fallback"
async def test_enrich_address_ignores_non_house_suggestions() -> None:
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
from app.services import dadata
street_only = {
"suggestions": [
{
"value": "г Екатеринбург, ул Малышева",
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
}
]
}
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
assert result is None
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
"""CLEAN недоступен + suggest пустой → None (graceful)."""
from app.services import dadata
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
with _patch_settings(), _patch_async_client(transport):
result = await dadata.enrich_address("Малышева 4")
assert result is None

View file

@ -0,0 +1,112 @@
"""Unit tests for `app.services.image_sanitizer.sanitize_image`.
Coverage:
- happy path: a small RGBA PNG re-encodes to a clean JPEG (RGB, EXIF stripped)
- resize path: an image above _MAX_DIMENSION is thumbnailed down, still decodes
- pixel-flood DoS: an image declaring > _MAX_PIXELS is rejected via img.size
BEFORE img.load() is ever called (no full decode no OOM) #2214
- byte-size backstop: content over _MAX_BYTES rejected before Image.open
- Pillow's own bomb threshold (MAX_IMAGE_PIXELS) is lowered to our cap
- garbage bytes still raise ImageSanitizationError (regression guard)
The oversized-dimension case is asserted via monkeypatch rather than a real
100 MP buffer: allocating the bomb is exactly the OOM we are defending against,
so the test proves img.load() is NOT reached.
"""
from __future__ import annotations
import io
import os
from unittest.mock import MagicMock
import pytest
# DATABASE_URL required by config before any app import (см. sibling service tests).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from PIL import Image
from app.services import image_sanitizer
from app.services.image_sanitizer import (
SANITIZED_CONTENT_TYPE,
ImageSanitizationError,
sanitize_image,
)
def _png_bytes(width: int, height: int, mode: str = "RGBA") -> bytes:
buf = io.BytesIO()
Image.new(mode, (width, height), color=(120, 60, 30, 255)[: len(mode)]).save(buf, format="PNG")
return buf.getvalue()
def test_small_image_passes_and_becomes_jpeg() -> None:
out, ctype = sanitize_image(_png_bytes(100, 80))
assert ctype == SANITIZED_CONTENT_TYPE
# Output is a valid JPEG, decoded back in RGB (transparency dropped).
reloaded = Image.open(io.BytesIO(out))
assert reloaded.format == "JPEG"
assert reloaded.mode == "RGB"
assert reloaded.size == (100, 80)
def test_oversized_but_under_pixel_cap_is_resized() -> None:
# 3000x2000 = 6 MP (< 40 MP cap) but longest edge > _MAX_DIMENSION → thumbnailed.
out, _ = sanitize_image(_png_bytes(3000, 2000, mode="RGB"))
reloaded = Image.open(io.BytesIO(out))
assert max(reloaded.size) == image_sanitizer._MAX_DIMENSION
assert reloaded.size == (2400, 1600)
def test_pixel_flood_rejected_without_decode(monkeypatch) -> None:
"""Image declaring > cap pixels is rejected via img.size, never img.load()."""
fake_img = MagicMock()
fake_img.size = (10_000, 10_000) # 100 MP >> 40 MP cap
fake_img.__enter__.return_value = fake_img
fake_img.__exit__.return_value = False
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
with pytest.raises(ImageSanitizationError) as excinfo:
sanitize_image(b"\x89PNG\r\n\x1a\n-tiny-header")
assert "exceeds" in str(excinfo.value)
# The whole point of the fix: no full decode happened → no hundreds-of-MB buffer.
fake_img.load.assert_not_called()
def test_pixel_cap_boundary_allows_at_cap(monkeypatch) -> None:
"""Exactly _MAX_PIXELS is allowed through to load() (strict '>' comparison)."""
fake_img = MagicMock()
fake_img.size = (image_sanitizer._MAX_PIXELS, 1) # width*height == cap
fake_img.mode = "RGB"
fake_img.__enter__.return_value = fake_img
fake_img.__exit__.return_value = False
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
monkeypatch.setattr(image_sanitizer.ImageOps, "exif_transpose", lambda im: im)
sanitize_image(b"whatever")
fake_img.load.assert_called_once()
def test_byte_size_backstop_rejects_before_open(monkeypatch) -> None:
called = {"open": False}
def _guard(*_a, **_k):
called["open"] = True
raise AssertionError("Image.open must not be called for oversized bytes")
monkeypatch.setattr(image_sanitizer.Image, "open", _guard)
with pytest.raises(ImageSanitizationError):
sanitize_image(b"\x00" * (image_sanitizer._MAX_BYTES + 1))
assert called["open"] is False
def test_pillow_bomb_threshold_lowered_to_cap() -> None:
assert image_sanitizer._MAX_PIXELS == 40_000_000
assert Image.MAX_IMAGE_PIXELS == image_sanitizer._MAX_PIXELS
def test_garbage_bytes_rejected() -> None:
with pytest.raises(ImageSanitizationError):
sanitize_image(b"definitely not an image")

View file

@ -40,9 +40,21 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
def test_top7_weight_sum_matches_ptica() -> None:
"""Same category set as Site Finder → identical top-7 normalization constant (31.5)."""
"""Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
assert lc._TOP7_WEIGHT_SUM == 31.5
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
"""Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.
Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 the theoretical max with all
top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
strong central address) into the bottom third of the range. The new normalization halves
the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
scores instead.
"""
assert lc._REF_DISTANCE_M == 100.0
assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9
def test_score_to_coef_bounds() -> None:
@ -142,6 +154,45 @@ def test_compute_location_coef_weights_and_ranks_top_n() -> None:
assert 0.95 <= result.coef <= 1.05
def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
"""Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.
Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 coef ~0.978
(verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
it should land close to 1.0 (within the documented ±0.01 calibration target).
"""
rows = [
{"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
{"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
{"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
{"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
{"name": "Школа №10", "category": "school", "distance_m": 350.0},
{"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
{"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
]
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert len(result.factors) == 7
assert abs(result.coef - 1.0) <= 0.01
def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
"""A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
rows = [
{"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
{"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
]
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert result.coef < 0.98
def test_compute_location_coef_limits_to_top_n() -> None:
"""More than top_n candidates → only top_n factors surface in the response."""
rows = [

View file

@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
from app.services.brand import Brand # noqa: E402
from app.services.exporters import trade_in_pdf as mod # noqa: E402
@ -79,7 +79,16 @@ class _WellFormed(HTMLParser):
"""Minimal balanced-tag check: every non-void tag that opens must close."""
_VOID: ClassVar[set[str]] = {
"br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
"br",
"img",
"hr",
"meta",
"input",
"rect",
"path",
"circle",
"line",
"polyline",
}
def __init__(self) -> None:
@ -224,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
assert "Ожидаемая цена продажи" in html
# existing sections untouched
assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
# (остался на стр. 03) — cover ужат до одной A4-страницы.
assert "Диапазон цен по фактическим сделкам" not in html
def test_build_cover_without_sold_renders_only_asking() -> None:
@ -234,4 +245,151 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
assert "Ожидаемая цена продажи" not in html
# existing layout intact
assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
assert "Диапазон цен по фактическим сделкам" not in html
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────
def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
return AnalogLot(
address="Екатеринбург, ул. Ленина, 1",
area_m2=50.0,
rooms=2,
floor=3,
total_floors=9,
price_rub=price_rub,
price_per_m2=int(price_rub / 50),
listing_date=None,
days_on_market=days_on_market,
)
def test_days_range_none_when_no_lots() -> None:
# Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
assert mod._days_on_market_range([]) is None
def test_days_range_none_when_all_days_missing() -> None:
assert mod._days_on_market_range([_lot(None), _lot(None)]) is None
def test_days_range_min_max_when_data_present() -> None:
assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)
def test_cover_without_days_data_still_renders_range_chart() -> None:
# Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
est = _estimate(analogs=[_lot(None)])
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "Диапазон цен в объявлениях" in html
# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───
def test_report_number_ekb_prefix_for_ekb_address() -> None:
est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
assert mod._report_number(est).startswith("ЕКБ-")
def test_report_number_city_prefix_for_non_ekb_address() -> None:
assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
"НТ-"
)
assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")
def test_report_number_neutral_prefix_when_city_unknown() -> None:
# None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
"МЕРА-"
)
def test_report_number_never_hardcodes_old_ekb_literal() -> None:
# Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")
def test_report_number_falls_back_to_canonical_address() -> None:
est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
assert mod._report_number(est).startswith("ПРВ-")
# ── Static «1018%» haircut replaced by computed «N%» (#pdf-honesty) ──────────
def test_cover_advice_uses_computed_discount_not_static_range() -> None:
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # 20%
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
assert "1018%" not in html
assert "на 20%" in html
def test_cover_advice_neutral_when_no_discount() -> None:
html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
assert "1018%" not in html
assert "проходят ниже цен в объявлениях" in html
def test_deals_banner_uses_computed_discount_not_static_range() -> None:
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # 20%
html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "1018%" not in html
# Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
assert "Екатеринбург, 2026" not in html
assert "на 20%" in html
def test_deals_banner_neutral_when_no_discount() -> None:
html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "1018%" not in html
assert "продаются дешевле, чем заявлено" in html
# ── _build_offer_page — rent scales with room count, not a flat 1-room figure ──
# (#pdf-honesty LOW audit R2 #5: «Общие финансовые потери» hardcoded a 1-room
# 3-month rent for EVERY object, including 3-4-room ones.)
def test_offer_page_rent_scales_up_for_larger_objects() -> None:
est = _estimate()
snapshot_1room = {**_SNAPSHOT, "rooms": 1}
snapshot_3room = {**_SNAPSHOT, "rooms": 3}
html_1room = mod._build_offer_page(est, snapshot_1room, _BRAND)
html_3room = mod._build_offer_page(est, snapshot_3room, _BRAND)
_assert_well_formed(html_1room)
_assert_well_formed(html_3room)
# Base (1-room) rent range unchanged: 84 000 135 000.
assert mod._fmt_rub(84_000) in html_1room
assert mod._fmt_rub(135_000) in html_1room
# 3-room rent scaled up (×1.8 heuristic): 151 000 243 000.
assert mod._fmt_rub(151_000) in html_3room
assert mod._fmt_rub(243_000) in html_3room
# The flat 1-room figure must NOT leak into the 3-room object's report.
assert mod._fmt_rub(135_000) not in html_3room
def test_offer_page_rent_subtitle_reflects_room_count() -> None:
est = _estimate()
html_1room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 1}, _BRAND)
assert "1-комн. квартиры на 3 месяца" in html_1room
assert "однокомнатной квартиры на 3 месяца" not in html_1room
html_3room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 3}, _BRAND)
assert "3-комн. квартиры на 3 месяца" in html_3room
# Unknown room count (0/missing) → neutral subtitle, no fabricated room number.
html_unknown = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 0}, _BRAND)
assert "квартиры на 3 месяца" in html_unknown
assert "-комн. квартиры на 3 месяца" not in html_unknown

View file

@ -82,7 +82,6 @@ def _run_qa_estimate(
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
radius_analogs: list[dict[str, Any]] | None = None,
) -> Any:
from app.core.config import settings
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import estimate_quality
from app.services.geocoder import GeocodeResult
@ -105,7 +104,6 @@ def _run_qa_estimate(
async def _run() -> Any:
with (
patch.object(settings, "estimate_same_building_anchor_enabled", True),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -6,6 +6,9 @@ Coverage:
(c) increment растит used счётчик
(d) get_status корректен для different сценариев
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
(f) #747 — атомарно-условный increment (TOCTOU fix)
(g) account_quota_overrides персональный лимит вместо negative-used хака
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
DB мокируется через MagicMock реальная БД не требуется.
"""
@ -14,7 +17,9 @@ from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
# psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -29,6 +34,7 @@ import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
from app.services.account_quota import ( # noqa: E402
LIMIT_EXHAUSTED_MESSAGE,
MONTHLY_LIMIT,
@ -36,6 +42,7 @@ from app.services.account_quota import ( # noqa: E402
get_status,
increment,
is_unlimited,
user_limit,
)
# ---------------------------------------------------------------------------
@ -43,23 +50,54 @@ from app.services.account_quota import ( # noqa: E402
# ---------------------------------------------------------------------------
def _db_with_used(used: int) -> MagicMock:
"""DB session mock whose execute().fetchone() returns row with .used = N."""
row = MagicMock()
row.used = used
execute_result = MagicMock()
execute_result.fetchone.return_value = row
class _Row:
"""Row stand-in поддерживающий и .used/.monthly_limit, и индексный доступ row[0]."""
def __init__(self, value: int) -> None:
self.used = value
self.monthly_limit = value
self._t = (value,)
def __getitem__(self, i: int) -> int:
return self._t[i]
def _result(row: _Row | None) -> MagicMock:
result = MagicMock()
result.fetchone.return_value = row
return result
def _override_result(limit: int | None) -> MagicMock:
"""Mock результата запроса account_quota_overrides.monthly_limit (user_limit())."""
return _result(None if limit is None else _Row(limit))
def _used_result(used: int | None) -> MagicMock:
"""Mock результата запроса account_estimate_usage.used (или UPSERT RETURNING used)."""
return _result(None if used is None else _Row(used))
def _db_with_used(used: int, *, override_limit: int | None = None) -> MagicMock:
"""DB session mock для get_status/check_and_raise: ровно 2 execute() —
(1) user_limit() override lookup, (2) account_estimate_usage.used lookup."""
db = MagicMock()
db.execute.return_value = execute_result
db.execute.side_effect = [_override_result(override_limit), _used_result(used)]
return db
def _db_no_row() -> MagicMock:
"""DB session mock where no row exists yet (first estimate of the month)."""
execute_result = MagicMock()
execute_result.fetchone.return_value = None
def _db_no_row(*, override_limit: int | None = None) -> MagicMock:
"""DB session mock где строки usage ещё нет (первая оценка месяца)."""
db = MagicMock()
db.execute.return_value = execute_result
db.execute.side_effect = [_override_result(override_limit), _used_result(None)]
return db
def _db_for_increment(*, override_limit: int | None, upsert_used: int | None) -> MagicMock:
"""DB session mock для increment(): (1) user_limit() override lookup,
(2) UPSERT ... RETURNING used (None если WHERE used < lim не матчит)."""
db = MagicMock()
db.execute.side_effect = [_override_result(override_limit), _used_result(upsert_used)]
return db
@ -127,7 +165,7 @@ def test_check_and_raise_pilot_not_blocked_at_14() -> None:
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
Логика: used >= MONTHLY_LIMIT block. После 15-й успешной оценки
Логика: used >= limit block. После 15-й успешной оценки
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
"""
db = _db_with_used(MONTHLY_LIMIT)
@ -153,7 +191,7 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
def test_check_and_raise_pilot_blocked_over_limit() -> None:
"""used=20 тоже блокируется."""
"""used=20 тоже блокируется (нет override → глобальный лимит)."""
db = _db_with_used(20)
from fastapi import HTTPException
@ -168,14 +206,15 @@ def test_check_and_raise_pilot_blocked_over_limit() -> None:
def test_increment_pilot_calls_upsert() -> None:
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
db = MagicMock()
increment(db, "user1")
db.execute.assert_called_once()
"""increment для pilot → выполняет user_limit lookup + UPSERT (2 execute), 1 commit."""
db = _db_for_increment(override_limit=None, upsert_used=1)
result = increment(db, "user1")
assert result is True
assert db.execute.call_count == 2
db.commit.assert_called_once()
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
call_args = db.execute.call_args
# Второй вызов — UPSERT; проверяем что SQL содержит ON CONFLICT ... DO UPDATE
call_args = db.execute.call_args_list[1]
sql_text = str(call_args[0][0]) # first positional arg — text() object
assert "ON CONFLICT" in sql_text
assert "used" in sql_text
@ -190,9 +229,9 @@ def test_increment_none_username_is_noop() -> None:
def test_increment_pilot_first_estimate_of_month() -> None:
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
db = MagicMock()
db = _db_for_increment(override_limit=None, upsert_used=1)
increment(db, "user5")
db.execute.assert_called_once()
assert db.execute.call_count == 2
# ---------------------------------------------------------------------------
@ -212,7 +251,7 @@ def test_get_status_none_username() -> None:
def test_get_status_admin() -> None:
"""admin → unlimited, remaining=15 вне зависимости от used."""
"""admin → unlimited, remaining=limit вне зависимости от used."""
db = _db_with_used(7)
status = get_status(db, "admin")
assert status["unlimited"] is True
@ -396,18 +435,7 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None
# ---------------------------------------------------------------------------
class _Row:
"""Row stand-in поддерживающий и .used, и индексный доступ row[0]."""
def __init__(self, used: int) -> None:
self.used = used
self._t = (used,)
def __getitem__(self, i: int) -> int:
return self._t[i]
class _Result:
class _AtomicResult:
def __init__(self, row: _Row | None) -> None:
self._row = row
@ -416,28 +444,33 @@ class _Result:
class _AtomicQuotaFakeDB:
"""In-memory fake, моделирующий атомарный conditional UPSERT (#747).
Воспроизводит семантику `INSERT ... ON CONFLICT DO UPDATE SET used=used+1
WHERE used < :lim RETURNING used`:
- строки ещё нет (used=None) INSERT used=1, RETURNING row (WHERE не для INSERT);
- used < lim used+=1, RETURNING row;
- used >= lim конфликтная строка НЕ обновлена, RETURNING пуст (None).
"""In-memory fake, воспроизводящий user_limit() override-lookup + атомарный
conditional UPSERT (#747):
- execute() без ключа "lim" в params user_limit() override lookup
возвращает override_limit (или None increment() падает на глобальный
MONTHLY_LIMIT как :lim в следующем вызове).
- execute() с ключом "lim" UPSERT: строки ещё нет (used=None) INSERT used=1,
RETURNING row (WHERE не применяется к INSERT); used < lim used+=1, RETURNING
row; used >= lim конфликтная строка НЕ обновлена, RETURNING пуст (None).
"""
def __init__(self, *, used: int | None) -> None:
def __init__(self, *, used: int | None, override_limit: int | None = None) -> None:
self.used = used
self.override_limit = override_limit
self.commits = 0
def execute(self, _stmt: object, params: dict) -> _Result:
def execute(self, _stmt: object, params: dict) -> _AtomicResult:
if "lim" not in params:
row = _Row(self.override_limit) if self.override_limit is not None else None
return _AtomicResult(row)
lim = params["lim"]
if self.used is None:
self.used = 1
elif self.used < lim:
self.used += 1
else:
return _Result(None)
return _Result(_Row(self.used))
return _AtomicResult(None)
return _AtomicResult(_Row(self.used))
def commit(self) -> None:
self.commits += 1
@ -472,3 +505,207 @@ def test_increment_atomic_unlimited_noop_returns_true() -> None:
assert increment(db, "admin") is True
assert db.commits == 0
assert db.used == MONTHLY_LIMIT # не тронут
def test_increment_atomic_respects_override_limit() -> None:
"""override=50 (user2): used=49 → increment True (used→50); used=50 → False.
Глобальный MONTHLY_LIMIT (15) НЕ применяется используется персональный override."""
db = _AtomicQuotaFakeDB(used=49, override_limit=50)
assert increment(db, "user2") is True
assert db.used == 50
assert increment(db, "user2") is False
assert db.used == 50 # не 51
def test_increment_atomic_override_below_global_blocks_early() -> None:
"""override=5 (ниже глобального 15): used=5 уже блокирует, хотя < MONTHLY_LIMIT.
Демонстрирует что per-user override заменяет глобальный лимит полностью
не является дополнительным потолком поверх него.
"""
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
assert increment(db, "user_low_override") is False
assert db.used == 5 # не выросло
# ---------------------------------------------------------------------------
# (g) account_quota_overrides — персональный лимит (замена negative-used хака)
# ---------------------------------------------------------------------------
def test_user_limit_no_override_returns_global() -> None:
"""Нет строки в account_quota_overrides → user_limit() возвращает MONTHLY_LIMIT."""
db = MagicMock()
db.execute.return_value = _override_result(None)
assert user_limit(db, "user1") == MONTHLY_LIMIT
def test_user_limit_with_override_returns_override() -> None:
"""Есть строка override → user_limit() возвращает monthly_limit из неё."""
db = MagicMock()
db.execute.return_value = _override_result(50)
assert user_limit(db, "user2") == 50
def test_get_status_override_limit() -> None:
"""user2 с override=50, used=0 (после сброса хака) → limit=50, remaining=50."""
db = _db_with_used(0, override_limit=50)
status = get_status(db, "user2")
assert status["limit"] == 50
assert status["remaining"] == 50
assert status["used"] == 0
def test_get_status_override_remaining_clamped_even_if_used_negative() -> None:
"""Кламп: даже если used снова просочится отрицательным (regression прежнего
negative-used хака), remaining НЕ превышает limit не «Осталось 50 из 15»."""
db = _db_with_used(-35, override_limit=50)
status = get_status(db, "user2")
assert status["limit"] == 50
assert status["used"] == -35 # raw used не скрываем — диагностическая честность
assert status["remaining"] == 50 # clamp: max(0, 50 - max(0, -35)) == 50
assert status["remaining"] <= status["limit"]
def test_check_and_raise_override_blocks_below_global_limit() -> None:
"""override=5 (ниже глобального 15) — used=5 блокируется, хотя < MONTHLY_LIMIT."""
db = _db_with_used(5, override_limit=5)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
check_and_raise(db, "user_low_override")
assert exc_info.value.status_code == 429
def test_check_and_raise_override_allows_above_global_limit() -> None:
"""override=50 — used=20 (> глобального 15) НЕ блокируется."""
db = _db_with_used(20, override_limit=50)
check_and_raise(db, "user2") # не должно поднять исключение
def test_increment_override_blocks_at_override_not_global() -> None:
"""increment уважает per-user override: used>=override → False, даже если
used < MONTHLY_LIMIT (15)."""
db = _db_for_increment(override_limit=5, upsert_used=None) # WHERE used<5 не матчит
assert increment(db, "user_low_override") is False
def test_increment_override_allows_above_global_limit() -> None:
"""increment с override=50: used=20 (>15 глобального) успешно инкрементит."""
db = _db_for_increment(override_limit=50, upsert_used=21)
assert increment(db, "user2") is True
# ---------------------------------------------------------------------------
# (h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
# ---------------------------------------------------------------------------
def _insufficient_estimate() -> AggregatedEstimate:
"""Пустой результат — как _empty_estimate() в estimator.py: median=0 → #697
computed_field insufficient_data=True."""
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=0,
range_low_rub=0,
range_high_rub=0,
median_price_per_m2=0,
confidence="low",
confidence_explanation="address_not_geocoded",
n_analogs=0,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
def _real_estimate() -> AggregatedEstimate:
"""Непустой результат — insufficient_data=False (median_price_rub > 0)."""
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=5_000_000,
range_low_rub=4_500_000,
range_high_rub=5_500_000,
median_price_per_m2=100_000,
confidence="medium",
n_analogs=8,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
)
@pytest.fixture()
def estimate_app_ok() -> FastAPI:
"""FastAPI app где check_and_raise проходит (used=0, без override) —
estimate_quality мокается отдельно в каждом тесте."""
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def _override_db():
yield _db_no_row()
application.dependency_overrides[get_db] = _override_db
return application
def test_estimate_insufficient_data_does_not_increment_quota(
estimate_app_ok: FastAPI,
) -> None:
"""POST /estimate с insufficient_data=True результатом (нерезолвящийся адрес) →
account_quota.increment НЕ вызывается (пустой результат не списывает платный слот)."""
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
with (
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_insufficient_estimate()),
),
patch("app.services.account_quota.increment") as mock_increment,
):
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, несуществующий адрес xyz",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["median_price_rub"] == 0
assert resp.json()["insufficient_data"] is True
mock_increment.assert_not_called()
def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) -> None:
"""Контрольный тест: непустой результат (insufficient_data=False) — increment
вызывается как обычно (регресс-guard, не ломаем #747 TOCTOU-семантику)."""
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
with (
patch(
"app.services.estimator.estimate_quality",
new=AsyncMock(return_value=_real_estimate()),
),
patch("app.services.account_quota.increment", return_value=True) as mock_increment,
):
resp = client.post(
"/api/v1/trade-in/estimate",
json={
"address": "г. Екатеринбург, ул. Малышева, 1",
"area_m2": 50.0,
"rooms": 2,
},
headers={"X-Authenticated-User": "user1"},
)
assert resp.status_code == 200
assert resp.json()["insufficient_data"] is False
mock_increment.assert_called_once()

View file

@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None:
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
Миграция 177 залила ДКП по всей обл.66, а asking (listings) только ЕКБ. Без скоупа
sold-медиана смешивала дешёвую область ratio 0.8770.62, «выкупная» 29%. Оба
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны НЕ трогаем.
"""
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
_a = _REDERIVE_SQL.index("ask_side AS")
_b = _REDERIVE_SQL.index("per_bucket AS")
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
def _strip_sql(s: str) -> str:
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
@ -159,9 +175,14 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
s,
)
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql(
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL))
)
def _drop_city_guard(s: str) -> str:
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
def _norm(s: str) -> str:
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
# ── Counters query ────────────────────────────────────────────────────────────

View file

@ -0,0 +1,344 @@
"""Tests for app.api.v1.audit — admin read API over `user_events` (Feature 2/3).
Coverage:
(a) static SQL guard CAST(:x AS type), never `:x::type` (psycopg v3 trap).
(b) GET /audit/accounts empty table [] (never errors); populated shape-valid
AccountSummary rows pass through response_model unchanged.
(c) GET /audit/accounts/{username} empty table (incl. unknown username, NOT 404)
all 4 lists == []; populated shape-valid drilldown rows.
(d) GET /analytics empty table zeroed summary + empty lists (never errors);
populated shape-valid dashboard bundle, `days` query param clamped [1, 365].
(e) optional real-Postgres round trip inserts a couple of user_events rows and
asserts aggregation through the real DB; self-SKIPS without a reachable,
non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB
pattern).
Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py
and the quota_app fixture in tests/test_user_events.py) no need to pull in the full
app.main (sentry/scheduler/CORS/rate-limit wiring).
"""
from __future__ import annotations
import inspect
import os
import re
import uuid
from datetime import UTC, date, datetime
from typing import Any
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import audit as audit_module
from app.core.db import get_db
# ---------------------------------------------------------------------------
# (a) Static SQL guard
# ---------------------------------------------------------------------------
_AUDIT_SRC = inspect.getsource(audit_module)
def test_no_psycopg_v3_doublecolon_cast() -> None:
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
assert not re.search(r":\w+::\w", _AUDIT_SRC)
def test_days_param_uses_cast_as_int() -> None:
assert "CAST(:days AS int)" in _AUDIT_SRC
# ---------------------------------------------------------------------------
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
# ---------------------------------------------------------------------------
class _FakeMappingResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def all(self) -> list[dict[str, Any]]:
return list(self._rows)
def one(self) -> dict[str, Any]:
return self._rows[0]
class _FakeExecResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _FakeMappingResult:
return _FakeMappingResult(self._rows)
class _FakeSession:
"""Returns queued row-lists in the exact order audit.py issues db.execute() calls."""
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
self._responses = list(responses)
self._i = 0
def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult:
rows = self._responses[self._i]
self._i += 1
return _FakeExecResult(rows)
def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI:
application = FastAPI()
application.include_router(audit_module.router, prefix="/api/v1/admin")
def _override_db() -> Any:
yield _FakeSession(responses)
application.dependency_overrides[get_db] = _override_db
return application
_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC)
# ---------------------------------------------------------------------------
# (b) GET /audit/accounts
# ---------------------------------------------------------------------------
def test_list_accounts_empty_table() -> None:
app = _make_app(responses=[[]])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts")
assert resp.status_code == 200
assert resp.json() == []
def test_list_accounts_populated_shape() -> None:
row = {
"username": "user1",
"first_seen_at": _NOW,
"last_seen_at": _NOW,
"distinct_ips": 3,
"distinct_devices": 2,
"login_count": 5,
"request_count": 40,
"search_count": 7,
}
app = _make_app(responses=[[row]])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["username"] == "user1"
assert data[0]["distinct_ips"] == 3
assert data[0]["login_count"] == 5
assert data[0]["request_count"] == 40
assert data[0]["search_count"] == 7
# ---------------------------------------------------------------------------
# (c) GET /audit/accounts/{username}
# ---------------------------------------------------------------------------
def test_account_drilldown_empty_table_unknown_user_not_404() -> None:
"""Unknown username → 200 with all 4 lists empty, NOT a 404."""
app = _make_app(responses=[[], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz")
assert resp.status_code == 200
data = resp.json()
assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []}
def test_account_drilldown_populated_shape() -> None:
ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}]
devices = [
{"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}
]
searches = [
{
"address": "ул. Малышева, 1",
"area_m2": "50.0",
"rooms": "2",
"estimate_id": str(uuid.uuid4()),
"ip_address": "1.2.3.4",
"created_at": _NOW,
}
]
recent_activity = [
{
"event_type": "api_request",
"path": "/api/v1/trade-in/estimate",
"method": "POST",
"ip_address": "1.2.3.4",
"created_at": _NOW,
}
]
app = _make_app(responses=[ips, devices, searches, recent_activity])
client = TestClient(app)
resp = client.get("/api/v1/admin/audit/accounts/user1")
assert resp.status_code == 200
data = resp.json()
assert data["ips"][0]["ip_address"] == "1.2.3.4"
assert data["ips"][0]["event_count"] == 10
assert data["devices"][0]["user_agent"] == "pytest-agent"
assert data["searches"][0]["address"] == "ул. Малышева, 1"
assert data["searches"][0]["area_m2"] == "50.0"
assert data["recent_activity"][0]["event_type"] == "api_request"
assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate"
# ---------------------------------------------------------------------------
# (d) GET /analytics
# ---------------------------------------------------------------------------
_ZERO_SUMMARY = {
"total_events": 0,
"distinct_users": 0,
"events_last_24h": 0,
"active_users_last_24h": 0,
}
def test_analytics_empty_table_never_errors() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
data = resp.json()
assert data["summary"] == _ZERO_SUMMARY
assert data["daily"] == []
assert data["top_searches"] == []
assert data["top_paths"] == []
assert data["by_account"] == []
def test_analytics_populated_shape() -> None:
summary = {
"total_events": 120,
"distinct_users": 4,
"events_last_24h": 15,
"active_users_last_24h": 2,
}
daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}]
top_searches = [{"address": "ул. Малышева, 1", "n": 3}]
top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}]
by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}]
app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
data = resp.json()
assert data["summary"]["total_events"] == 120
assert data["daily"][0]["events"] == 15
assert data["daily"][0]["day"] == "2026-07-13"
assert data["top_searches"][0]["address"] == "ул. Малышева, 1"
assert data["top_paths"][0]["n"] == 40
assert data["by_account"][0]["username"] == "user1"
def test_analytics_days_query_param_clamped() -> None:
"""days=0 (below ge=1) → 422; days=9999 (above le=365) → 422."""
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
assert client.get("/api/v1/admin/analytics?days=0").status_code == 422
assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422
def test_analytics_days_query_param_default_30() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics")
assert resp.status_code == 200
def test_analytics_days_query_param_accepted_in_range() -> None:
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
client = TestClient(app)
resp = client.get("/api/v1/admin/analytics?days=7")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# (e) Optional real-Postgres round trip (self-skips without a reachable DB)
# ---------------------------------------------------------------------------
def _live_session() -> Any | None:
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
try:
from sqlalchemy import create_engine
from sqlalchemy import text as _t
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(_t("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None:
"""End-to-end on a real DB: insert 2 user_events rows for a throwaway test username,
assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them."""
from sqlalchemy import text as _t
from app.core.db import SessionLocal
db = _live_session()
assert db is not None
username = f"audit_test_{uuid.uuid4().hex[:8]}"
try:
db.execute(
_t(
"""
INSERT INTO user_events
(event_type, username, ip_address, user_agent, path, method, payload)
VALUES
('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL,
CAST('{}' AS jsonb)),
('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua',
'/api/v1/trade-in/estimate', 'POST',
CAST(:payload AS jsonb))
"""
),
{"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'},
)
db.commit()
application = FastAPI()
application.include_router(audit_module.router, prefix="/api/v1/admin")
def _override_db() -> Any:
yield SessionLocal()
application.dependency_overrides[get_db] = _override_db
client = TestClient(application)
accounts = client.get("/api/v1/admin/audit/accounts").json()
row = next(r for r in accounts if r["username"] == username)
assert row["login_count"] == 1
assert row["search_count"] == 1
drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json()
assert len(drilldown["searches"]) == 1
assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1"
assert len(drilldown["recent_activity"]) == 2
dashboard = client.get("/api/v1/admin/analytics?days=1").json()
assert dashboard["summary"]["total_events"] >= 2
finally:
db.rollback()
db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username})
db.commit()

View file

@ -0,0 +1,84 @@
"""Avito SERP city-slug filter (#2487 oblast rollout).
`_parse_html` drops padding cards ("по всей России") when `avito_serp_ekb_only`
is on. The kept city-slug used to be hardcoded `/ekaterinburg/`, so an oblast
per-city sweep (`_job_avito_city_sweep` with a `city` param) discarded 100% of the
target city's cards. The slug is now parameterized via `AvitoScraper.target_city_slug`
(default "ekaterinburg", i.e. ЕКБ behavior unchanged when no target is set).
No network / no DB `_parse_html` runs on inline HTML with a stub config.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.serp import AvitoScraper
_BASE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?p=1"
def _card(slug: str, item_id: str, price: int = 5_000_000) -> str:
"""Minimal Avito SERP card whose per-card href carries `slug` as the city segment."""
href = f"/{slug}/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
return (
f'<div data-marker="item" data-item-id="{item_id}">'
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
f'<meta itemprop="price" content="{price}">'
f"</div>"
)
def _html(*cards: str) -> str:
return "<html><body>" + "".join(cards) + "</body></html>"
def _ekb_only_cfg() -> SimpleNamespace:
# _parse_html only reads config.avito_serp_ekb_only; a stub keeps the test
# deterministic regardless of AVITO_SERP_EKB_ONLY env in the runner.
return SimpleNamespace(avito_serp_ekb_only=True)
def test_parse_keeps_target_oblast_city_drops_off_target_padding() -> None:
"""Sweep targeting Н.Тагил keeps its cards; ЕКБ/Москва padding is dropped."""
s = AvitoScraper(_ekb_only_cfg(), target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
html = _html(
_card("nizhniy_tagil", "nt1"), # target city → KEPT
_card("ekaterinburg", "ekb1"), # off-target padding → DROPPED
_card("moskva", "msk1"), # off-target padding → DROPPED
)
lots = s._parse_html(html, source_url_base=_BASE_URL)
urls = [lot.source_url or "" for lot in lots]
assert len(lots) == 1, urls
assert "/nizhniy_tagil/" in urls[0]
assert all("/ekaterinburg/" not in u for u in urls)
assert all("/moskva/" not in u for u in urls)
def test_parse_default_target_is_ekb_and_drops_oblast_padding() -> None:
"""No target (ЕКБ sweep/full-load): kept slug defaults to 'ekaterinburg' — behavior
identical to the previous hardcode. ЕКБ card kept, oblast padding dropped."""
s = AvitoScraper(_ekb_only_cfg(), target_city_slug=None) # type: ignore[arg-type]
html = _html(
_card("ekaterinburg", "e1"), # ЕКБ → KEPT
_card("nizhniy_tagil", "nt1"), # off-target padding → DROPPED
)
lots = s._parse_html(html, source_url_base=_BASE_URL)
urls = [lot.source_url or "" for lot in lots]
assert len(lots) == 1, urls
assert "/ekaterinburg/" in urls[0]
assert all("/nizhniy_tagil/" not in u for u in urls)
def test_parse_ekb_only_disabled_keeps_all_cities() -> None:
"""avito_serp_ekb_only=False → no city filter at all, every card is kept."""
cfg = SimpleNamespace(avito_serp_ekb_only=False)
s = AvitoScraper(cfg, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
html = _html(_card("nizhniy_tagil", "nt1"), _card("ekaterinburg", "e1"))
lots = s._parse_html(html, source_url_base=_BASE_URL)
assert len(lots) == 2

View file

@ -832,3 +832,177 @@ def test_render_full_table_handles_empty_sample() -> None:
out = bt._render_full_table(m)
assert "n/a" in out # None metrics render as n/a, no crash
assert "BACKTEST" in out
# --------------------------------------------------------------------------- #
# Oblast D — `--city` deal-sample scoping (parse + SQL predicate + PPM2 band).
# --------------------------------------------------------------------------- #
def test_argparse_city_defaults_none() -> None:
assert bt._parse_args([]).city is None
def test_argparse_city_override() -> None:
ns = bt._parse_args(["--city", "Нижний Тагил"])
assert ns.city == "Нижний Тагил"
def test_sample_sql_default_is_unscoped_and_same_object() -> None:
# city=None must return the SAME _SAMPLE_SQL object used before oblast D —
# literal identity, not just equal text — so the default CLI invocation (and
# the frozen EKB regression gate, which never calls this path at all) see a
# byte-identical query.
assert bt._sample_sql(None) is bt._SAMPLE_SQL
def test_sample_sql_default_has_no_city_predicate() -> None:
assert ":city" not in bt._SAMPLE_SQL.text
def test_sample_sql_city_scoped_adds_city_predicate() -> None:
sql = bt._sample_sql("Нижний Тагил")
assert sql is not bt._SAMPLE_SQL
built = sql.text
assert "AND city = CAST(:city AS text)" in built
# City-scoping is additive — the base predicates are still present.
assert "source = 'rosreestr'" in built
assert "deal_date >= CAST(:since AS date)" in built
assert "ORDER BY id DESC" in built
# --------------------------------------------------------------------------- #
# Oblast D — _resolve_city_ppm2_band (per-city PPM2 sanity band, no live DB).
# --------------------------------------------------------------------------- #
class _FakeBandResult:
"""Minimal stand-in for a SQLAlchemy Result exposing .mappings().first()."""
def __init__(self, row: dict[str, object] | None) -> None:
self._row = row
def mappings(self) -> "_FakeBandResult":
return self
def first(self) -> dict[str, object] | None:
return self._row
class _FakeBandSession:
"""Minimal stand-in for a Session — only .execute() is exercised here."""
def __init__(self, row: dict[str, object] | None = None, *, raise_exc: bool = False) -> None:
self._row = row
self._raise = raise_exc
def execute(self, *_args: object, **_kwargs: object) -> _FakeBandResult:
if self._raise:
raise RuntimeError("boom")
return _FakeBandResult(self._row)
def test_resolve_city_ppm2_band_none_city_returns_globals_no_query() -> None:
# city=None must not even touch the DB (no _FakeBandSession.execute call).
class _NoExecuteSession:
def execute(self, *_a: object, **_kw: object) -> None:
raise AssertionError("must not query DB when city is None")
assert bt._resolve_city_ppm2_band(_NoExecuteSession(), None) == (
float(bt.PPM2_MIN),
float(bt.PPM2_MAX),
)
def test_resolve_city_ppm2_band_found_row() -> None:
db = _FakeBandSession({"ppm2_min": 16955, "ppm2_max": 108175})
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (16955.0, 108175.0)
def test_resolve_city_ppm2_band_no_row_falls_back_to_globals() -> None:
db = _FakeBandSession(None)
assert bt._resolve_city_ppm2_band(db, "Екатеринбург") == (
float(bt.PPM2_MIN),
float(bt.PPM2_MAX),
)
def test_resolve_city_ppm2_band_db_error_falls_back_to_globals() -> None:
db = _FakeBandSession(raise_exc=True)
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (
float(bt.PPM2_MIN),
float(bt.PPM2_MAX),
)
# --------------------------------------------------------------------------- #
# Oblast C2 parity — _predict_full_spine must resolve + pass a target city to
# _fetch_dkp_corridor, mirroring estimate_quality (estimator.py:3210-3220).
# --------------------------------------------------------------------------- #
def test_predict_full_spine_passes_resolved_city_to_corridor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from types import SimpleNamespace
from app.services import estimator as est_mod
captured: dict[str, object] = {}
def _fake_resolve_target_city(address: str | None) -> str | None:
return "нижний тагил"
def _fake_fetch_dkp_corridor(
_db: object,
*,
address: object,
rooms: object,
area: object,
city: object = None,
**_kw: object,
) -> None:
captured["called"] = True
captured["city"] = city
return None
def _fake_price_from_inputs(**_kwargs: object) -> SimpleNamespace:
return SimpleNamespace(
median_price=100_000.0,
median_ppm2=100_000.0,
confidence="low",
anchor_tier=None,
expected_sold_per_m2=95_000.0,
expected_sold_price=4_750_000.0,
expected_sold_range_low=4_000_000.0,
expected_sold_range_high=5_500_000.0,
)
monkeypatch.setattr(est_mod, "_resolve_target_city", _fake_resolve_target_city)
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", _fake_fetch_dkp_corridor)
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
monkeypatch.setattr(bt, "_select_analogs_full", lambda *a, **kw: ([], "W", False, False))
est = bt._import_estimator_full()
deal = bt.DealSample(
id=1,
lon=60.6,
lat=58.05,
rooms=2,
sold_ppm2=90_000.0,
deal_date=None,
area_m2=50.0,
address="Нижний Тагил, ул. Ленина, 5",
floor=3,
total_floors=9,
year_built=2000,
house_type="панель",
)
pred = bt._predict_full_spine(None, deal, est)
assert captured.get("called") is True
assert captured.get("city") == "нижний тагил"
assert pred is not None

View file

@ -0,0 +1,443 @@
"""Тесты ДОМ.РФ капремонт loader'а (app/services/domrf_kapremont_loader.py, мигр. 176, #2013).
Coverage:
- Чистые парс-хелперы: parse_decimal_comma / parse_int_field.
- parse_kr11_csv / parse_kr12_wall_materials фикстуры собраны из РЕАЛЬНЫХ строк
региона 66 (export-kr1_1-66-20260701.csv / export-kr1_2-66-20260701.csv, дом
2170017185 «Ломоносова 3» и 2170025270 «Ломоносова 4»): КР1.2 long-format,
wall_material непустой только на строке construction_element_type='фасад'.
- build_domrf_houses join КР1.1+КР1.2 по mkd_code.
- fetch_domrf_csvs скачивание+распаковка zip, HTTP замокан через httpx.MockTransport
(никакой живой сети).
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
backfill houses (COALESCE-порядок year_built/domrf/zhkh_year, only-fill-NULL),
propagate listings (только NULL listings.year_built).
- backfill_houses_from_domrf / propagate_listings_year_from_houses dry_run вызывает
COUNT-SQL и не пишет, real-run суммирует rowcount обоих шагов (MagicMock db).
"""
from __future__ import annotations
import io
import os
import re
import zipfile
from pathlib import Path
from unittest.mock import MagicMock
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
# sibling-тестах (test_zhkh_flats_loader) задаём заглушку (ядро статично/без реальной БД).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import httpx
import pytest
from app.services import domrf_kapremont_loader as dkl
# ─────────────────────────────────────────────────────────────────────────────
# Фикстуры: РЕАЛЬНЫЕ строки региона 66 (export-kr1_1/kr1_2-66-20260701.csv)
# ─────────────────────────────────────────────────────────────────────────────
# КР1.1 house registry — 2 дома (Ломоносова 3 / Ломоносова 4, п. Арамиль).
_KR11_HEADER = (
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;houseguid;address;commission_year;"
"architectural_monument_category;total_sq;total_rooms_amount;living_rooms_amount;"
"total_rooms_sq;living_rooms_sq;total_ppl;number_floors_max;money_collecting_way;"
"money_ppl_collected;money_ppl_collected_debts;overhaul_funds_spent_all;"
"overhaul_funds_spent_subsidy;overhaul_fund_spent_other;overhaul_funds_balance;"
"update_date_of_information;money_ppl_collected_date;owners_payment;energy_efficiency;"
"alarm_document_date;exclude_date_from_program;inclusion_date_to_program;comment;"
"last_update;house_id"
)
_KR11_ROW_3 = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170017185;04b98bcb-3232-4b26-b0fa-3c4a47adf394;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
'1964;;399,90;;;366,60;333,30;20;2;"Счет регионального оператора";486,84407;72,59638;'
"1580,29712;0,00000;0,00000;-1093,45305;2026-05-31;01.10.2014;18,81;"
'"не присвоен";;;22.04.2014;;15.05.2026;8857551'
)
_KR11_ROW_4 = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170025270;15064d04-3aec-4266-985b-da26e9a5f392;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
'1984;;906,40;;;;;;2;"Счет регионального оператора";1046,79431;204,75296;0,00000;'
'0,00000;0,00000;1046,79431;2026-05-31;01.10.2014;18,81;"не присвоен";;;22.04.2014;;'
"15.05.2026;8857552"
)
KR11_CSV = "\n".join([_KR11_HEADER, _KR11_ROW_3, _KR11_ROW_4]) + "\n"
# КР1.2 constructive elements — long-format: обычная строка (wall_material пуст) +
# строка "фасад" (wall_material='кирпич') на каждый из тех же 2 домов.
_KR12_HEADER = (
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;address;construction_element_code;"
"construction_element_type;system_type;lift_type;stops_count;commissioning_date;"
"decommissioning_date;roof_type;roofing_area;basement_area;facade_type;facade_area;"
"foundation_type;wall_material;comment;last_update"
)
_KR12_ROW_3_OTHER = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170017185;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
'580000;"система дымоудаления и (или) противопожарной автоматики";;;;;;;;;;;;;;15.05.2026'
)
_KR12_ROW_3_FACADE = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170017185;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
'60000;фасад;;;;;;;;;"облицованный камнем";480,00;;кирпич;;15.05.2026'
)
_KR12_ROW_4_OTHER = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170025270;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
'100000;"инженерная система водоснабжения (ХВС)";центральная;;;;;;;;;;;;;15.05.2026'
)
_KR12_ROW_4_FACADE = (
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
"2170025270;"
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
"60000;фасад;;;;;;;;;;;;кирпич;;15.05.2026"
)
KR12_CSV = (
"\n".join(
[_KR12_HEADER, _KR12_ROW_3_OTHER, _KR12_ROW_3_FACADE, _KR12_ROW_4_OTHER, _KR12_ROW_4_FACADE]
)
+ "\n"
)
@pytest.fixture
def kr11_path(tmp_path: Path) -> Path:
p = tmp_path / "kr11.csv"
p.write_text(KR11_CSV, encoding="utf-8-sig")
return p
@pytest.fixture
def kr12_path(tmp_path: Path) -> Path:
p = tmp_path / "kr12.csv"
p.write_text(KR12_CSV, encoding="utf-8-sig")
return p
# ─────────────────────────────────────────────────────────────────────────────
# parse_decimal_comma / parse_int_field
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_decimal_comma() -> None:
assert dkl.parse_decimal_comma("909,80") == pytest.approx(909.80)
assert dkl.parse_decimal_comma("399,90") == pytest.approx(399.90)
assert dkl.parse_decimal_comma("100.5") == pytest.approx(100.5)
assert dkl.parse_decimal_comma("") is None
assert dkl.parse_decimal_comma(None) is None
assert dkl.parse_decimal_comma("не число") is None
def test_parse_int_field() -> None:
assert dkl.parse_int_field("1964") == 1964
assert dkl.parse_int_field("2") == 2
assert dkl.parse_int_field("") is None
assert dkl.parse_int_field(None) is None
assert dkl.parse_int_field("x") is None
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
def test_parse_int_field_year_sanity_gate() -> None:
# Fix A: значение вне [min_value, max_value] → None (мусорный год не в staging).
ymax = dkl.plausible_year_max()
assert dkl.parse_int_field("1841", min_value=1850, max_value=ymax) is None # слишком старый
assert dkl.parse_int_field("0", min_value=1850, max_value=ymax) is None # ноль
assert dkl.parse_int_field(str(ymax + 5), min_value=1850, max_value=ymax) is None # будущее
assert dkl.parse_int_field("1964", min_value=1850, max_value=ymax) == 1964 # валидный
assert dkl.parse_int_field("1850", min_value=1850, max_value=ymax) == 1850 # граница вкл.
# Без границ (number_floors_max) — гейт не применяется.
assert dkl.parse_int_field("1841") == 1841
def test_parse_kr11_gates_impossible_commission_year(tmp_path: Path) -> None:
# Fix A: строка КР1.1 с невозможным годом (1200) → commission_year=None.
bad_row = _KR11_ROW_3.replace("1964;", "1200;", 1)
csv_text = "\n".join([_KR11_HEADER, bad_row]) + "\n"
p = tmp_path / "bad_year.csv"
p.write_text(csv_text, encoding="utf-8-sig")
rows = dkl.parse_kr11_csv(p)
assert rows["2170017185"].commission_year is None
# ─────────────────────────────────────────────────────────────────────────────
# parse_kr11_csv — house registry
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_kr11_csv_real_rows(kr11_path: Path) -> None:
rows = dkl.parse_kr11_csv(kr11_path)
assert set(rows.keys()) == {"2170017185", "2170025270"}
house3 = rows["2170017185"]
assert house3.houseguid == "04b98bcb-3232-4b26-b0fa-3c4a47adf394"
assert house3.commission_year == 1964
assert house3.number_floors_max == 2
assert house3.total_sq == pytest.approx(399.90)
assert "Ломоносова, д. 3" in (house3.address or "")
house4 = rows["2170025270"]
assert house4.houseguid == "15064d04-3aec-4266-985b-da26e9a5f392"
assert house4.commission_year == 1984
assert house4.total_sq == pytest.approx(906.40)
def test_parse_kr11_csv_skips_blank_mkd_code(tmp_path: Path) -> None:
csv_text = _KR11_HEADER + "\n" + (";" * (_KR11_HEADER.count(";"))) + "\n"
p = tmp_path / "blank.csv"
p.write_text(csv_text, encoding="utf-8-sig")
assert dkl.parse_kr11_csv(p) == {}
# ─────────────────────────────────────────────────────────────────────────────
# parse_kr12_wall_materials — long-format, wall_material только на строке 'фасад'
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_kr12_wall_materials_extracts_facade_row_only(kr12_path: Path) -> None:
materials = dkl.parse_kr12_wall_materials(kr12_path)
# Обе non-facade строки (дымоудаление / ХВС) имели wall_material='' — исключены.
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
def test_parse_kr12_wall_materials_empty_when_no_facade_row(tmp_path: Path) -> None:
csv_text = "\n".join([_KR12_HEADER, _KR12_ROW_3_OTHER]) + "\n"
p = tmp_path / "no_facade.csv"
p.write_text(csv_text, encoding="utf-8-sig")
assert dkl.parse_kr12_wall_materials(p) == {}
# ─────────────────────────────────────────────────────────────────────────────
# build_domrf_houses — join КР1.1 + КР1.2(wall_material)
# ─────────────────────────────────────────────────────────────────────────────
def test_build_domrf_houses_joins_wall_material(kr11_path: Path, kr12_path: Path) -> None:
kr11 = dkl.parse_kr11_csv(kr11_path)
walls = dkl.parse_kr12_wall_materials(kr12_path)
houses = dkl.build_domrf_houses(kr11, walls)
by_code = {h.mkd_code: h for h in houses}
assert len(houses) == 2
assert by_code["2170017185"].wall_material == "кирпич"
assert by_code["2170017185"].commission_year == 1964
assert by_code["2170025270"].wall_material == "кирпич"
# Дом без строки 'фасад' в КР1.2 → wall_material остаётся None (не KeyError/пустая строка).
def test_wall_material_none_when_house_missing_from_kr12(tmp_path: Path, kr11_path: Path) -> None:
kr11 = dkl.parse_kr11_csv(kr11_path)
houses = dkl.build_domrf_houses(kr11, {}) # пустой КР1.2 lookup
assert all(h.wall_material is None for h in houses)
# ─────────────────────────────────────────────────────────────────────────────
# fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан (MockTransport)
# ─────────────────────────────────────────────────────────────────────────────
def _zip_bytes(inner_name: str, content: str) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(inner_name, content.encode("utf-8-sig"))
return buf.getvalue()
def test_fetch_domrf_csvs_downloads_and_extracts(tmp_path: Path) -> None:
kr11_zip = _zip_bytes("export-kr1_1-66-20260701.csv", KR11_CSV)
kr12_zip = _zip_bytes("export-kr1_2-66-20260701.csv", KR12_CSV)
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url == dkl.KR11_URL:
return httpx.Response(200, content=kr11_zip)
if url == dkl.KR12_URL:
return httpx.Response(200, content=kr12_zip)
raise AssertionError(f"unexpected URL {url}")
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport) as client:
kr11_out, kr12_out = dkl.fetch_domrf_csvs(tmp_path, client=client)
assert kr11_out.exists() and kr11_out.name.endswith(".csv")
assert kr12_out.exists() and kr12_out.name.endswith(".csv")
# Круглый путь: распакованные файлы парсятся так же, как локальные фикстуры.
rows = dkl.parse_kr11_csv(kr11_out)
assert set(rows.keys()) == {"2170017185", "2170025270"}
materials = dkl.parse_kr12_wall_materials(kr12_out)
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("readme.txt", "not a csv")
with pytest.raises(ValueError, match="csv"):
dkl._extract_csv_from_zip(buf.getvalue(), tmp_path)
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
# ─────────────────────────────────────────────────────────────────────────────
_UPSERT_SQL = str(dkl._UPSERT_SQL.text)
def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
assert not re.search(r":\w+::", _UPSERT_SQL)
assert "CAST(:mkd_code AS text)" in _UPSERT_SQL
assert "CAST(:commission_year AS smallint)" in _UPSERT_SQL
assert "CAST(:total_sq AS numeric)" in _UPSERT_SQL
def test_upsert_sql_idempotent_on_conflict() -> None:
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
assert "INSERT INTO domrf_kapremont" in flat
assert "ON CONFLICT (mkd_code) DO UPDATE SET" in flat
assert "domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid" in flat
assert "domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
def test_upsert_uses_savepoint_per_chunk() -> None:
import inspect
src = inspect.getsource(dkl.upsert_domrf_kapremont)
assert "with db.begin_nested():" in src
assert "db.commit()" not in src # не коммитит сам (коммитит caller)
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts: backfill houses — COALESCE(year_built, domrf, zhkh_year)
# ─────────────────────────────────────────────────────────────────────────────
_BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQL.text))
_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_SQL.text))
def test_backfill_domrf_sql_year_priority_and_plausibility_guard() -> None:
bound = "BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)"
# Fix A: year_built — валидное существующее (в границах :ymin..:ymax) выигрывает,
# иначе (NULL/impossible) заменяется авторитетным commission_year → zhkh_year.
assert f"WHEN h.year_built {bound}" in _BACKFILL_DOMRF_SQL
assert "THEN h.year_built" in _BACKFILL_DOMRF_SQL
# commission_year и zhkh_year тоже проходят плаузибилити-гейт перед записью.
assert f"CASE WHEN d.commission_year {bound}" in _BACKFILL_DOMRF_SQL
# Невозможный существующий год НЕ экранирует замену (WHERE ловит NOT BETWEEN).
assert f"h.year_built NOT {bound}" in _BACKFILL_DOMRF_SQL
# material_walls / total_floors — прежняя COALESCE-семантика (только заполнить NULL).
assert "COALESCE(h.material_walls, d.wall_material)" in _BACKFILL_DOMRF_SQL
assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL
def test_backfill_domrf_sql_matches_by_guid_coalesce_priority() -> None:
# gar_house_guid первый (78% coverage), затем house_fias_id, затем zhkh_house_guid.
assert (
"d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
in _BACKFILL_DOMRF_SQL
)
def test_backfill_domrf_sql_only_fills_null_never_overwrites() -> None:
# Гейт: каждый SET-таргет требует h.<col> IS NULL — COALESCE никогда не перезапишет
# существующее non-null значение (SQL COALESCE тоже это гарантирует, но гейт защищает
# от no-op UPDATE на уже заполненных строках).
assert "h.year_built IS NULL" in _BACKFILL_DOMRF_SQL
assert "h.material_walls IS NULL" in _BACKFILL_DOMRF_SQL
assert "h.total_floors IS NULL" in _BACKFILL_DOMRF_SQL
def test_backfill_zhkh_fallback_sql_folds_zhkh_for_unmatched_houses() -> None:
# Шаг 2 — без JOIN на domrf_kapremont вообще: применяется к ЛЮБОМУ дому, включая
# те, что не заматчились в шаге 1 (issue: "houses without a ДОМ.РФ match still benefit").
assert "domrf_kapremont" not in _BACKFILL_ZHKH_SQL
assert "COALESCE(year_built, zhkh_year)" in _BACKFILL_ZHKH_SQL
assert "COALESCE(total_floors, zhkh_floors)" in _BACKFILL_ZHKH_SQL
assert "year_built IS NULL AND zhkh_year IS NOT NULL" in _BACKFILL_ZHKH_SQL
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
assert not re.search(r":\w+::", _BACKFILL_DOMRF_SQL)
assert not re.search(r":\w+::", _BACKFILL_ZHKH_SQL)
# ─────────────────────────────────────────────────────────────────────────────
# backfill_houses_from_domrf — dry_run vs real-run (MagicMock db)
# ─────────────────────────────────────────────────────────────────────────────
def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None:
db = MagicMock()
results = [MagicMock(rowcount=3), MagicMock(rowcount=2)]
db.execute.side_effect = results
out = dkl.backfill_houses_from_domrf(db, dry_run=False)
assert out == {"domrf_matched": 3, "zhkh_fallback": 2, "houses_updated": 5}
assert db.execute.call_count == 2
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
assert "UPDATE houses" in executed_sql[0]
assert "UPDATE houses" in executed_sql[1]
# Fix A: domrf-шаг прокидывает плаузибилити-границы года как bind-параметры.
domrf_params = db.execute.call_args_list[0].args[1]
assert domrf_params["ymin"] == dkl.YEAR_BUILT_MIN
assert domrf_params["ymax"] == dkl.plausible_year_max()
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
db = MagicMock()
db.execute.return_value.scalar_one.side_effect = [7, 4]
out = dkl.backfill_houses_from_domrf(db, dry_run=True)
assert out == {"domrf_matched": 7, "zhkh_fallback": 4, "houses_updated": 0}
for call in db.execute.call_args_list:
sql_text = str(call.args[0]).upper()
assert "UPDATE" not in sql_text
assert "SELECT COUNT(*)" in sql_text
# ─────────────────────────────────────────────────────────────────────────────
# Статические asserts + behavior: propagate_listings_year_from_houses
# ─────────────────────────────────────────────────────────────────────────────
_PROPAGATE_SQL = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_SQL.text))
def test_propagate_sql_only_fills_null_listings_year() -> None:
assert "SET year_built = h.year_built" in _PROPAGATE_SQL
assert "l.year_built IS NULL" in _PROPAGATE_SQL
assert "h.year_built IS NOT NULL" in _PROPAGATE_SQL
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
def test_propagate_sql_has_link_consistency_distance_guard() -> None:
# Fix B: пропагация только если координаты объявления в пределах порога от дома
# (ST_DWithin, geography → метры) — перепривязанный FK не впрыснет чужой год.
assert "ST_DWithin(" in _PROPAGATE_SQL
assert "CAST(l.geom AS geography)" in _PROPAGATE_SQL
assert "CAST(h.geom AS geography)" in _PROPAGATE_SQL
assert str(dkl.PROPAGATE_MAX_DISTANCE_M) in _PROPAGATE_SQL
# Оба SQL (UPDATE и COUNT) несут один и тот же guard.
count_sql = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_COUNT_SQL.text))
assert "ST_DWithin(" in count_sql
def test_propagate_sql_no_coords_falls_back_to_address_check() -> None:
# Fix B: объявление без координат → консервативный address-фоллбек
# (адрес объявления содержит short_address дома), не пропагируем «вслепую».
assert "l.geom IS NULL" in _PROPAGATE_SQL
assert "h.short_address IS NOT NULL" in _PROPAGATE_SQL
assert "l.address ILIKE" in _PROPAGATE_SQL
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 42
out = dkl.propagate_listings_year_from_houses(db, dry_run=False)
assert out == {"listings_updated": 42}
sql_text = str(db.execute.call_args[0][0])
assert "UPDATE listings" in sql_text
def test_propagate_listings_year_dry_run_uses_count_sql() -> None:
db = MagicMock()
db.execute.return_value.scalar_one.return_value = 99
out = dkl.propagate_listings_year_from_houses(db, dry_run=True)
assert out == {"listings_updated": 0, "would_update": 99}
sql_text = str(db.execute.call_args[0][0]).upper()
assert "UPDATE" not in sql_text

View file

@ -139,6 +139,9 @@ def _stub_precision_and_pdf():
_fetch_price_trend=lambda *a, **k: None,
_fetch_dkp_corridor=lambda *a, **k: None,
_fetch_house_imv_anchor=lambda *a, **k: None,
# (oblast C2): GET-rehydrate also resolves the target city (for
# _fetch_dkp_corridor city-scoping) before calling the corridor helper.
_resolve_target_city=lambda *a, **k: None,
# #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
_cv_from_ppm2=lambda *a, **k: None,

View file

@ -0,0 +1,261 @@
"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs
и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей
одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»).
Два независимых root cause (см. PR):
1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len
выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip)
anchor_comps клип-отсечённый ценовой выброс оставался видимым в UI, даже
когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь =
anchor["comps"] (тот же пост-clip пул, что дал n).
2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения
ЦЕНОВОГО бакета (price_bucket) реальный кросс-пост одного физлота на разных
площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/
дороже), который может увести пару в разные бакеты дубль выживает в
популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) тот же
физический ключ (building/floor/area), но БЕЗ price_bucket ловит именно эти
остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая
n_analogs/median/cv (frozen backtest regression gate не задет см.
tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает).
Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая
ЦЕЛИКОМ является ценовым выбросом, «Гоголя 18» из QA-смоука): MAD-clip убирает
ОБЕ копии из anchor["comps"] они пропадают из карточек естественным образом
(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов.
Test B покрывает менее тривиальный случай дубль-пара НЕ является ценовым
выбросом (обычная цена, просто с небольшим дрейфом между площадками) и
переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only
дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) тест
документирует именно это, а не искусственное равенство.
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
test_same_building_anchor.py (anchor_tier="A" same-building anchor ветка, где
живёт MAD-clip).
"""
from __future__ import annotations
import importlib.util
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import estimator
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST)
assert _spec is not None and _spec.loader is not None
_h = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_h)
def _comp(
*,
source: str,
address: str,
ppm2: float,
area_m2: float = 146.2,
rooms: int = 4,
scraped_at: datetime | None = None,
) -> dict[str, Any]:
"""Минимальный same-building comp-dict (без floor/total_floors → нейтральный
floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0).
"""
return {
"source": source,
"source_url": f"https://{source}.example/offer/{address}/{ppm2}",
"address": address,
"area_m2": area_m2,
"rooms": rooms,
"price_per_m2": ppm2,
"price_rub": ppm2 * area_m2,
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
"photo_urls": [],
}
# --------------------------------------------------------------------------- #
# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии
# --------------------------------------------------------------------------- #
def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None:
"""5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source
дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы,
как «Гоголя 18» в QA-смоуке) MAD-clip убирает ОБЕ копии из
anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из
исходного anchor_comps), выброс не просачивается в UI количество карточек
равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках.
"""
clean = [
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0),
]
# Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком
# ценовой выброс, как реальный прод-кейс «Гоголя 18»).
outlier_dup = [
_comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0),
_comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0),
]
est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A")
# Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту
# часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых).
assert est.n_analogs == 5
# Главный инвариант display-consistency: показанные карточки == n_analogs.
assert len(est.analogs) == est.n_analogs == 5
# Ни одна из копий выброса не попала в карточки.
shown_addresses = {a.address for a in est.analogs}
assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses
shown_ppm2 = {a.price_per_m2 for a in est.analogs}
assert 900_000 not in shown_ppm2
assert 920_000 not in shown_ppm2
# Нет дублей адресов среди карточек.
assert len(shown_addresses) == len(est.analogs)
# --------------------------------------------------------------------------- #
# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip,
# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её
# пропускает из-за ценового дрейфа, бакет-straddle).
# --------------------------------------------------------------------------- #
def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
"""4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф
2-25к, «Пушкина 9»-паттерн из QA-смоука) НЕ выброс, переживает MAD-clip как
2 отдельные записи (n_analogs их считает по отдельности, статистика не
тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ
карточку карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а
следствие того, что стат-пайплайн и display-дедуп используют разные ключи
по дизайну см. docstring _dedup_display_lots). Инвариант, который держит
этот тест: адрес дубля встречается в карточках РОВНО один раз.
"""
clean = [
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
]
# Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% —
# достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.)
# развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах
# MAD-clip диапазона остальных 4).
dup_a = _comp(
source="yandex",
address="Екатеринбург, ул. Пушкина, 9",
ppm2=219_000.0,
area_m2=142.0,
scraped_at=datetime(2026, 6, 1, tzinfo=UTC),
)
dup_b = _comp(
source="domklik",
address="Екатеринбург, ул. Пушкина, 9",
ppm2=222_500.0,
area_m2=142.0,
scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель
)
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
# иначе тест проверял бы не то, что заявлено в docstring.
assert estimator.settings.estimate_dedup_analogs_enabled is True
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A")
# Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не
# выброс) — n_analogs = 6.
assert est.n_analogs == 6
# display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs.
assert len(est.analogs) == 5
assert len(est.analogs) < est.n_analogs
# Инвариант: адрес дубля встречается РОВНО один раз среди карточек.
shown_addresses = [a.address for a in est.analogs]
assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1
# Представитель — свежайший scraped_at (domklik, 222_500).
pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9")
assert pushkina_card.price_per_m2 == 222_500
# --------------------------------------------------------------------------- #
# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а
# --------------------------------------------------------------------------- #
def _lot(
*,
source: str,
address: str = "ул. Ленина, 5",
area: float = 60.0,
price: float = 12_000_000.0,
floor: int | None = 5,
scraped_at: datetime | None = None,
) -> dict[str, Any]:
return {
"source": source,
"address": address,
"area_m2": area,
"price_rub": price,
"price_per_m2": price / area if area else 0,
"floor": floor,
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
"photo_urls": [],
}
def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None:
# Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат-
# дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует
# цену вовсе.
lots = [
_lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
_lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)),
]
out = estimator._dedup_display_lots(lots)
assert len(out) == 1
assert out[0]["source"] == "cian" # свежайший scraped_at
def test_dedup_display_lots_same_source_pair_not_merged() -> None:
# Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост
# (либо два разных реальных юнита, случайно совпавших по округлённой
# площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source
# guard).
lots = [
_lot(source="cian", price=12_000_000.0),
_lot(source="cian", price=13_000_000.0),
]
out = estimator._dedup_display_lots(lots)
assert len(out) == 2
def test_dedup_display_lots_different_addresses_stay_distinct() -> None:
lots = [
_lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0),
_lot(source="cian", address="ул. Мира, 3", price=12_000_000.0),
]
out = estimator._dedup_display_lots(lots)
assert len(out) == 2
def test_dedup_display_lots_noop_under_two_lots() -> None:
lots = [_lot(source="avito")]
out = estimator._dedup_display_lots(lots)
assert out is lots

View file

@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
assert anchor_raw is not None, "Должен построить якорь без гейта"
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
# _compute_same_building_anchor),
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
assert anchor_raw["confidence"] == "low"
# Проверяем что гейт-условие срабатывает:
@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> None:
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
комнатам его вес высок, и он не должен быть outlier после clip.
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
компл близок по площади/комнатам его вес высок, и он не должен быть outlier
после clip.
"""
# 4 стандартных компла + 1 видовой (выше на 30%)
base = 200_000
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
with patch("app.services.estimator.settings") as mock_settings:
# Активируем post-weight clip
mock_settings.estimate_sb_clip_after_weight = True
mock_settings.estimate_sb_mad_k_small_n = 2.5
mock_settings.estimate_sb_small_n_threshold = 10
mock_settings.avito_imv_thin_market_threshold = 10

View file

@ -0,0 +1,238 @@
"""Tests for the oblast-D deals-headline-fallback (non-EKB accuracy gap).
Context: `listings` coverage is ~40k EKB / ~0 non-EKB (Нижний Тагил / Серов /
Каменск-Уральский). Before this fix, a non-EKB subject either:
(a) leaked a DISTANT EKB listing median via the Tier S address-prefix
fallback (no geo bound see `_fetch_analogs` Tier S fix), or
(b) surfaced an empty ("n/a") headline once (a) was fixed and the
geo-bound radius tiers legitimately found 0 local listings.
This fallback builds the headline from the ДКП deal corridor (`dkp_raw`,
already city+street-scoped via `_resolve_target_city`/`_fetch_dkp_corridor`)
instead honestly flagged 'low' confidence, deals-only.
These tests exercise `estimate_quality` end-to-end (real spine code, mocked
DB-facing helpers only) the same pattern as test_estimator_radius_floor.py.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_listing(*, price_per_m2: float, area_m2: float = 45.0) -> dict[str, Any]:
"""An EKB analog — used only in the "EKB unaffected" control test."""
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Малышева, 30",
"lat": 56.838,
"lon": 60.595,
"rooms": 2,
"area_m2": area_m2,
"floor": 5,
"total_floors": 16,
"price_rub": price_per_m2 * area_m2,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 150.0,
"relevance_score": 0.1,
}
def _make_geo_tagil():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=57.9094,
lon=59.9789,
full_address="Свердловская обл., Нижний Тагил, ул. Ленина, 5",
provider="nominatim",
)
def _make_payload_tagil():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Нижний Тагил, ул. Ленина, 5",
area_m2=45.0,
rooms=2,
floor=5,
total_floors=9,
)
def _run_estimate(
*,
analogs: list[dict[str, Any]],
dkp_raw: dict[str, Any] | None,
geo: Any,
payload: Any,
) -> Any:
from app.services.estimator import estimate_quality
db = MagicMock()
async def _run() -> Any:
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
# Post Part-1 (geo-bound Tier S) reality for a non-EKB town: the
# radius/tier ladder legitimately returns NOTHING — tier='W' is
# what the always-executed final fallback tier returns.
return_value=(list(analogs), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
# ── Нижний Тагил: no local listings, deal corridor present ───────────────────
def test_non_ekb_empty_listings_uses_deals_headline() -> None:
"""0 local listings + a usable ДКП corridor → headline comes from deals.
Mirrors the reported Нижний Тагил gap: deal_median 85 911 /м² (accurate)
vs the old EKB-leaked asking headline 186 461 (~6x over). After the fix,
the headline must equal the deal corridor's median — nowhere near the
EKB-range figure and confidence must be honestly 'low' (deals-only, zero
scraped analogs).
"""
dkp_raw = {
"count": 12,
"low_ppm2": 70_000,
"median_ppm2": 85_911,
"high_ppm2": 100_000,
"period_months": 12,
}
est = _run_estimate(
analogs=[], # 0 listings — the honest post-geo-bound-fix reality
dkp_raw=dkp_raw,
geo=_make_geo_tagil(),
payload=_make_payload_tagil(),
)
assert est.median_price_per_m2 == 85_911, (
f"headline={est.median_price_per_m2} must equal the deal corridor "
f"median, not 0/n-a and nowhere near an EKB-range figure (~186k)"
)
assert est.median_price_per_m2 < 120_000, "must NOT be EKB-leaked (~186k)"
assert est.n_analogs == 0, "honest: zero scraped-listing analogs were used"
assert est.confidence == "low", "deals-only headline must be honestly low-confidence"
assert est.median_price_rub == round(85_911 * 45.0)
# Range should bracket the corridor's P10/P90, not collapse to a point.
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
def test_non_ekb_empty_listings_no_deals_stays_insufficient() -> None:
"""0 listings + NO deal corridor either → stays honest n/a (median=0).
Guards against the fallback inventing a number when there is truly no
signal at all (e.g. Каменск-Уральский with an unresolvable street).
"""
est = _run_estimate(
analogs=[],
dkp_raw=None,
geo=_make_geo_tagil(),
payload=_make_payload_tagil(),
)
assert est.median_price_per_m2 == 0
assert est.n_analogs == 0
assert est.confidence == "low"
def test_non_ekb_thin_deal_corridor_below_min_n_stays_insufficient() -> None:
"""Deal corridor exists but below DEALS_HEADLINE_FALLBACK_MIN_N → no fallback.
A single stale sold price should not become the town's headline.
"""
dkp_raw = {
"count": 1,
"low_ppm2": 70_000,
"median_ppm2": 85_911,
"high_ppm2": 100_000,
"period_months": 12,
}
est = _run_estimate(
analogs=[],
dkp_raw=dkp_raw,
geo=_make_geo_tagil(),
payload=_make_payload_tagil(),
)
assert est.median_price_per_m2 == 0
assert est.n_analogs == 0
# ── EKB control: dense local listings → deals-fallback must NOT engage ───────
def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
"""EKB has plenty of local listings — the radius-path headline must win,
NOT the deal corridor, even though dkp_raw is present (byte-green guard:
EKB must stay on the existing listings-median path unconditionally).
"""
from app.schemas.trade_in import TradeInEstimateInput
from app.services.geocoder import GeocodeResult
analogs = [
_make_listing(price_per_m2=140_000.0),
_make_listing(price_per_m2=145_000.0),
_make_listing(price_per_m2=150_000.0),
]
dkp_raw = {
"count": 20,
"low_ppm2": 120_000,
"median_ppm2": 144_000,
"high_ppm2": 160_000,
"period_months": 12,
}
geo = GeocodeResult(
lat=56.838,
lon=60.595,
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
provider="nominatim",
)
payload = TradeInEstimateInput(
address="ЕКБ, ул. Малышева, 30", area_m2=45.0, rooms=2, floor=5, total_floors=16
)
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw, geo=geo, payload=payload)
# Headline built from the LISTINGS median (~145k), not silently replaced —
# n_analogs must reflect the real listing count (deals-fallback never ran).
assert est.n_analogs == len(analogs)
assert 140_000 <= est.median_price_per_m2 <= 150_000

View file

@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
anchor_tier: str | None = "A",
):
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", True),
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),

View file

@ -5,22 +5,19 @@
blend, rehydrate, миграция), который наполнит median/sources_used без единого
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
38.45M, yandex_valuation). Mainline уже честен это belt-and-suspenders.
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
"""
from __future__ import annotations
import os
import sys
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
# pydantic Settings требует DATABASE_URL при инициализации.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# WeasyPrint stubbed in CI.
sys.modules.setdefault("weasyprint", MagicMock())
import app.services.estimator as est # noqa: E402
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
assert isinstance(explanation, str)
def test_flag_default_true() -> None:
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
from app.core.config import settings
assert settings.estimate_confidence_floor_no_analogs is True
def test_flag_off_does_not_downgrade() -> None:
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
Тест патчит settings через patch.object, вызывает функцию напрямую убеждается
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
через patch в estimate_quality (ниже).
"""
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
def test_enforce_zero_analog_low_always_downgrades() -> None:
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
confidence, _ = _enforce_zero_analog_low(
"high",
0,
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
sources_used=["yandex_valuation"],
)
assert confidence == "low" # сама функция не смотрит на settings
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
# _enforce_zero_analog_low не должна быть вызвана —
# мокаем её и убеждаемся что мок не тронут.
mock_guard = MagicMock(return_value=("high", "unchanged"))
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
confidence_in = "high"
n = 0
expl = "test"
if est.settings.estimate_confidence_floor_no_analogs:
confidence_in, expl = est._enforce_zero_analog_low(
confidence_in, n, expl, median_price=0, sources_used=[]
)
mock_guard.assert_not_called()

View file

@ -6,8 +6,7 @@
- комбинированный случай (несколько причин сразу);
- all-clear (нет причин recommended False);
- граничные значения порогов (>= high_value, >= wide_ratio);
- div-guard на диапазон (range_low None/0 без деления на ноль);
- выключенный флаг estimate_manual_review_enabled (False, []).
- div-guard на диапазон (range_low None/0 без деления на ноль).
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг метаданные: сериализация
без изменений, когда ручная оценка не рекомендована).
@ -36,14 +35,12 @@ ELITE_REASON = (
def _settings(
*,
enabled: bool = True,
high_value: int = 20_000_000,
wide_ratio: float = 1.9,
elite_ppm2: int = 250_000,
) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти поля."""
return SimpleNamespace(
estimate_manual_review_enabled=enabled,
estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio,
estimate_manual_review_elite_ppm2=elite_ppm2,
@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
assert WIDE_RANGE_REASON not in reasons
# ── Выключенный флаг ─────────────────────────────────────────────────────────
def test_disabled_flag_suppresses_all() -> None:
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
rec, reasons = _manual_review(
premium_building=True,
expected_sold_price=99_000_000,
confidence="low",
range_low=10_000_000,
range_high=99_000_000,
settings=_settings(enabled=False),
)
assert rec is False
assert reasons == []
# ── invariant recommended == bool(reasons) ───────────────────────────────────

View file

@ -1,7 +1,7 @@
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
с разными ext_item_id не double-count'ится в месячной медиане.
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
не double-count'ится в месячной медиане (dedup всегда включён).
"""
from __future__ import annotations
@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo
# ---------------------------------------------------------------------------
def test_dedup_enabled_flag_is_true_by_default() -> None:
"""estimate_price_trend_dedup_enabled дефолт True."""
from app.core.config import settings
assert settings.estimate_price_trend_dedup_enabled is True
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = False
mock_settings.estimate_price_trend_max_age_months = 6
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
calls = mock_db.execute.call_args_list
assert len(calls) >= 2
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
assert "distinct on" not in second_sql.lower()
def test_price_trend_returns_result_with_dedup_enabled() -> None:
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
def test_price_trend_returns_result_with_dedup() -> None:
"""Функция корректно возвращает точки тренда (dedup включён)."""
rows = [
{"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_000},
@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
assert result[0]["ppm2"] == 148_000
def test_price_trend_dedup_disabled_returns_same_points() -> None:
"""Флаг OFF не ломает возврат данных (backward-compat)."""
rows = [
{"month": "2025-11", "ppm2": 148_000},
{"month": "2025-12", "ppm2": 152_000},
{"month": "2026-01", "ppm2": 155_000},
]
mock_db = _make_mock_db([], rows)
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = False
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
assert result is not None
assert len(result) == 3
def test_price_trend_source1_preferred_over_dedup_path() -> None:
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
source1_rows = [
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
mock_db = _make_mock_db(source1_rows, [])
with patch("app.services.estimator.settings") as mock_settings:
mock_settings.estimate_price_trend_dedup_enabled = True
mock_settings.estimate_price_trend_max_age_months = 6
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)

View file

@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
def test_confidence_medium_via_4_unique_addresses() -> None:
# 4 unique addresses → medium branch (independent of IQR).
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=115, # IQR/median = 0.25 < 0.35 → medium
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=70,
q3=130, # IQR/median = 0.60 → not high
q3=130, # IQR/median = 0.60
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
assert level == "low"
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
# while the explanation admits "расширили радиус … из-за нехватки данных".
listings = _addr_lots(["a", "b", "c", "d"])
level, expl = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=84,
q3=116, # IQR/median = 0.32 → medium base, then forced low
fallback_radius_used=True,
listings=listings,
)
assert level == "low"
assert "расширили радиус" in expl
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
@ -313,7 +344,6 @@ def _clamp(
tier: str | None,
slack: float = 0.25,
min_n: int = 10,
enabled: bool = True,
median_price: int = 0,
range_low: int = 0,
range_high: int = 0,
@ -328,7 +358,6 @@ def _clamp(
anchor_tier=tier,
slack=slack,
min_n=min_n,
enabled=enabled,
)
@ -374,15 +403,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
assert new_ppm2 == 296_000
def test_corridor_clamp_disabled_flag_is_noop() -> None:
# enabled=False → точно старое поведение.
new_ppm2, _, _, _, clamped = _clamp(
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False
)
assert clamped is False
assert new_ppm2 == 296_000
def test_corridor_clamp_tier_none_clamps() -> None:
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
new_ppm2, _, _, _, clamped = _clamp(

View file

@ -379,7 +379,6 @@ def _run_estimate_qi(
dadata_cadnum: str | None,
qi_lookup_result: tuple[float, int] | None,
*,
flag_enabled: bool = True,
anchor_tier_override: str | None = None,
):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
@ -442,10 +441,6 @@ def _run_estimate_qi(
"app.services.estimator._lookup_quarter_indexes",
side_effect=_fake_lookup_indexes,
),
patch(
"app.services.estimator.settings.estimate_quarter_index_enabled",
flag_enabled,
),
):
return await estimate_quality(payload, db)
@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None:
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
flag_enabled=True,
)
expected_median = round(base_median * 1.2)
assert est.median_price_rub == expected_median
@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None:
assert "квартал" in est.confidence_explanation.lower()
def test_quarter_index_flag_off_exact_old_behavior() -> None:
"""При estimate_quarter_index_enabled=False — точное старое поведение."""
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
]
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
flag_enabled=False,
)
base_median = round(_BASE_PPM2 * _AREA)
assert est.median_price_rub == base_median
# ─────────────────────────────────────────────────────────────────────────────
# Guard-2: >0.6 аналогов в целевом квартале → no-op
# ─────────────────────────────────────────────────────────────────────────────
@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
flag_enabled=True,
)
# Медиана НЕ должна изменяться
assert est.median_price_rub == base_median
@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None:
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=None, # sparse
flag_enabled=True,
)
assert est.median_price_rub == base_median
@ -575,7 +552,6 @@ def test_bimodal_guard_skips_high_index_small_n() -> None:
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
flag_enabled=True,
)
assert est.median_price_rub == base_median
@ -594,7 +570,6 @@ def test_bimodal_guard_allows_high_index_large_n() -> None:
analogs=analogs_no_cadnum,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
flag_enabled=True,
)
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
@ -620,6 +595,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
payload = _make_payload_qi()
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
fake_comps = [
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
for _ in range(3)
]
fake_anchor = {
"anchor_ppm2": _BASE_PPM2 * 1.5,
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
@ -631,11 +610,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
"comp_max_ppm2": _BASE_PPM2 * 1.7,
"used_uplift": False,
"haircut": 0.05,
# display-consistency fix: contract now requires "comps" — the final
# post-MAD-clip survivors (see _compute_same_building_anchor docstring).
"comps": fake_comps,
}
fake_comps = [
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
for _ in range(3)
]
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)

View file

@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius нет.
Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за
флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
тексте (как test_estimator_price_trend_dedup.py).
@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -23,7 +23,7 @@ import pytest
import app.services.estimator as est
def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
def _capture_tier_sql() -> list[str]:
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
@ -40,63 +40,42 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
db.execute.side_effect = side_effect
with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled):
est._fetch_analogs(
db,
lat=56.83,
lon=60.6,
rooms=2,
area=50.0,
radius_m=2000,
full_address="г Екатеринбург, ул Малышева, д 30",
year_built=2010,
house_type="монолит",
total_floors=20,
target_house_id=123,
)
est._fetch_analogs(
db,
lat=56.83,
lon=60.6,
rooms=2,
area=50.0,
radius_m=2000,
full_address="г Екатеринбург, ул Малышева, д 30",
year_built=2010,
house_type="монолит",
total_floors=20,
target_house_id=123,
)
return captured
# ---------------------------------------------------------------------------
# Флаг по умолчанию
# ---------------------------------------------------------------------------
def test_radius_dedup_flag_default_true() -> None:
from app.core.config import settings
assert settings.estimate_radius_dedup_enabled is True
# ---------------------------------------------------------------------------
# SQL-фрагмент во всех тирах
# ---------------------------------------------------------------------------
def test_all_four_tiers_render() -> None:
sqls = _capture_tier_sql(dedup_enabled=True)
sqls = _capture_tier_sql()
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
def test_rn_dup_window_present_in_every_tier_when_enabled() -> None:
sqls = _capture_tier_sql(dedup_enabled=True)
def test_rn_dup_window_present_in_every_tier() -> None:
sqls = _capture_tier_sql()
for i, sql in enumerate(sqls):
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
def test_dup_filter_dropped_when_disabled_but_window_stays() -> None:
"""Флаг OFF: фильтр `AND rn_dup = 1` убран, окно остаётся (безвредно)."""
sqls = _capture_tier_sql(dedup_enabled=False)
assert len(sqls) == 4
for i, sql in enumerate(sqls):
assert "AS rn_dup" in sql, f"tier#{i}: окно должно оставаться даже при OFF"
assert "rn_dup = 1" not in sql, f"tier#{i}: фильтр должен исчезнуть при OFF"
def test_rn_dup_key_namespaced_and_freshest() -> None:
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
sql = _capture_tier_sql(dedup_enabled=True)[0]
sql = _capture_tier_sql()[0]
assert "PARTITION BY source" in sql
assert "'id:' || source_id::text" in sql
assert "'url:' || source_url" in sql
@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None:
import re
bad = re.compile(r":[a-z_]+::[a-z]")
for i, sql in enumerate(_capture_tier_sql(dedup_enabled=True)):
for i, sql in enumerate(_capture_tier_sql()):
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"

View file

@ -8,7 +8,6 @@
- radius median выше dkp_low × factor no-op (медиана не изменена)
- dkp_raw is None no-op (нет базы для floor)
- anchor-путь (anchor_tier != None) не затронут floor'ом
- флаг estimate_radius_floor_enabled=False no-op
"""
from __future__ import annotations
@ -75,7 +74,6 @@ def _run_estimate(
analogs: list[dict[str, Any]],
dkp_raw: dict[str, Any] | None,
*,
radius_floor_enabled: bool = True,
radius_floor_factor: float = 0.8,
) -> Any:
from app.core.config import settings
@ -109,7 +107,6 @@ def _run_estimate(
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled),
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
):
return await estimate_quality(payload, db)
@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None:
assert (
est.median_price_per_m2 < 100_000
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
# ── тест 4: флаг OFF → no-op ─────────────────────────────────────────────────
def test_radius_floor_disabled_no_op() -> None:
"""estimate_radius_floor_enabled=False → floor не применяется."""
analogs = [
_make_listing(price_per_m2=75_000.0),
_make_listing(price_per_m2=80_000.0),
_make_listing(price_per_m2=85_000.0),
]
dkp_raw = {
"count": 15,
"low_ppm2": 150_000,
"median_ppm2": 180_000,
"high_ppm2": 220_000,
"period_months": 12,
}
# Без флага — median не должна подняться
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run() -> Any:
with (
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(analogs), False, "S"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch(
"app.services.estimator._get_or_fetch_imv_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
patch.object(settings, "estimate_radius_floor_enabled", False),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
assert (
est.median_price_per_m2 < 100_000
), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"

View file

@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def]
return _h._make_payload(area=60.0, rooms=2)
def _run(*, flag: bool, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
with (
patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag),
patch.object(settings, "estimate_wide_corridor_threshold", threshold),
):
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
return _h._run_estimate(
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
anchor_tier=anchor_tier,
@ -63,15 +60,10 @@ _DISCLOSURE_MARKER = "разбит на секции"
# ---------------------------------------------------------------------------
# Флаг по умолчанию
# Порог по умолчанию
# ---------------------------------------------------------------------------
def test_disclosure_flag_default_on() -> None:
# Включено после валидации порога на prod-данных (corridor_pct median≈0.48).
assert settings.estimate_wide_corridor_disclosure_enabled is True
def test_threshold_default_1_2() -> None:
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
@ -83,21 +75,21 @@ def test_threshold_default_1_2() -> None:
# ---------------------------------------------------------------------------
def test_tier_a_wide_corridor_flag_on_downgrades_and_discloses() -> None:
"""Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure."""
on = _run(flag=True, anchor_tier="A")
def test_tier_a_wide_corridor_downgrades_and_discloses() -> None:
"""Tier A + corridor_pct > threshold → confidence понижен + disclosure."""
on = _run(anchor_tier="A")
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert pct > 0.6, "fixture должен давать коридор > 60%"
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
# confidence понижен относительно flag-OFF baseline (medium → low).
off = _run(flag=False, anchor_tier="A")
# confidence понижен относительно no-fire baseline (порог выше коридора).
off = _run(anchor_tier="A", threshold=99.0)
assert _downgrade_confidence(off.confidence) == on.confidence
assert on.confidence != off.confidence
def test_flag_off_no_change() -> None:
"""Флаг OFF → ничего не делает (ни confidence, ни explanation)."""
off = _run(flag=False, anchor_tier="A")
def test_threshold_above_corridor_no_change() -> None:
"""Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation)."""
off = _run(anchor_tier="A", threshold=99.0)
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
@ -108,7 +100,7 @@ def test_flag_off_no_change() -> None:
def test_corridor_fires_when_above_threshold() -> None:
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
on = _run(flag=True, anchor_tier="A", threshold=1.0)
on = _run(anchor_tier="A", threshold=1.0)
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert pct > 1.0
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
@ -117,7 +109,7 @@ def test_corridor_fires_when_above_threshold() -> None:
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
on = _run(anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
@ -125,8 +117,8 @@ def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
def test_does_not_touch_point_median_range() -> None:
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
off = _run(flag=False, anchor_tier="A")
on = _run(flag=True, anchor_tier="A")
off = _run(anchor_tier="A", threshold=99.0)
on = _run(anchor_tier="A")
assert on.median_price_rub == off.median_price_rub
assert on.range_low_rub == off.range_low_rub
assert on.range_high_rub == off.range_high_rub
@ -134,13 +126,13 @@ def test_does_not_touch_point_median_range() -> None:
def test_below_threshold_no_disclosure() -> None:
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
high_threshold = _run(flag=True, anchor_tier="A", threshold=5.0)
high_threshold = _run(anchor_tier="A", threshold=5.0)
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
def test_non_tier_a_not_affected() -> None:
"""Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON)."""
c = _run(flag=True, anchor_tier="C")
"""Не-Tier-A (Tier C) → disclosure не применяется."""
c = _run(anchor_tier="C")
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")

View file

@ -1,18 +1,33 @@
"""Тесты для ЕКБ bbox-хелперов geocoder (#1871).
"""Тесты для ЕКБ/oblast66 bbox-хелперов geocoder (#1871, oblast-66 expansion).
Покрывают:
- is_within_ekb_bbox_wide ingest-guard (avito_detail координаты);
- is_within_ekb_bbox geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
- инвариант WIDE TIGHT.
- инвариант WIDE TIGHT;
- OBLAST66_BBOX / is_within_oblast66_bbox генеральный bbox всей области (region 66),
инвариант OBLAST66 TIGHT, дальние города области, известное ограничение (Тюмень
внутри bbox координатно компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker word-boundary matching (не substring);
- _nominatim_query / _yandex_lookup accept-сайты two-pass tie-break (tight ЕКБ
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
"""
from unittest.mock import patch
import httpx
import pytest
from app.services.geocoder import (
EKB_BBOX_TIGHT,
EKB_BBOX_WIDE,
OBLAST66_BBOX,
_has_oblast_marker,
_names_non_ekb_city,
_nominatim_query,
_yandex_lookup,
is_within_ekb_bbox,
is_within_ekb_bbox_wide,
is_within_oblast66_bbox,
)
# Центр ЕКБ (Плотинка) — внутри обоих bbox.
@ -86,3 +101,225 @@ def test_wide_strictly_contains_tight() -> None:
for lon in (t_lon_min, t_lon_max):
assert is_within_ekb_bbox(lat, lon) is True
assert is_within_ekb_bbox_wide(lat, lon) is True
# ── OBLAST66_BBOX (region 66 expansion) ─────────────────────────────────────
def test_oblast66_strictly_contains_tight() -> None:
"""Инвариант: OBLAST66_BBOX ⊇ EKB_BBOX_TIGHT — генерализация не режет ЕКБ.
Мирроор test_wide_strictly_contains_tight, но для областного bbox.
"""
t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
o_lat_min, o_lat_max, o_lon_min, o_lon_max = OBLAST66_BBOX
assert o_lat_min <= t_lat_min
assert o_lat_max >= t_lat_max
assert o_lon_min <= t_lon_min
assert o_lon_max >= t_lon_max
for lat in (t_lat_min, t_lat_max):
for lon in (t_lon_min, t_lon_max):
assert is_within_ekb_bbox(lat, lon) is True
assert is_within_oblast66_bbox(lat, lon) is True
@pytest.mark.parametrize(
"lat,lon,label",
[
(60.6935, 60.4235, "Ивдель"),
(59.6297, 60.5541, "Серов"),
(58.0378, 65.2753, "Тавда"),
(56.6152, 57.7666, "Красноуфимск"),
],
)
def test_oblast66_accepts_far_towns(lat: float, lon: float, label: str) -> None:
"""Дальние города области (вне EKB tight/wide) — приняты oblast66-bbox."""
assert is_within_oblast66_bbox(lat, lon) is True, f"{label} должен быть внутри OBLAST66_BBOX"
def test_oblast66_bbox_admits_tyumen_by_design() -> None:
"""OBLAST66_BBOX — генеральный (щедрый) bbox, координатно admits Тюмень.
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
false-positive у границы, чем false-negative на корректном адресе области).
Реальное отсечение Тюмени происходит на accept-сайтах через region
cross-check см. test_nominatim_query_rejects_out_of_region_only_candidate /
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
"""
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
# ── _has_oblast_marker — word/phrase-boundary matching (не substring) ───────
@pytest.mark.parametrize(
"text,expected",
[
("екатеринбург, малышева 30", True),
("нижний тагил, ленина 10", True),
("свердловская область, серова 27", True),
("каменск-уральский, мира 1", True),
("заречный, мира 5", True), # ЗАТО-город (без district-префикса)
# false-positives, которые substring-match ловил ошибочно (#oblast66 bug):
("серова 27", False), # НЕ город "серов"
("ирбитская 5", False), # НЕ город "ирбит"
("асбестовский пер., 3", False), # НЕ город "асбест"
("невьянский пер., 10", False), # НЕ город "невьянск"
("богдановича, 22", False), # НЕ город "богданович"
("мкр заречный, мира 5", False), # район внутри города, не город-ЗАТО
("микрорайон заречный, мира 5", False),
("малышева 30", False), # обычный ЕКБ bare-адрес
],
)
def test_has_oblast_marker(text: str, expected: bool) -> None:
assert _has_oblast_marker(text) is expected
# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ─────────
@pytest.mark.parametrize(
"address,expected",
[
("Нижний Тагил, проспект Ленина, 1", True),
("г. Екатеринбург, проспект Ленина, 1", False),
("проспект Ленина, 1", False),
("Серова 27", False), # word-boundary — НЕ город "Серов"
("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный
("Каменск-Уральский, ул. Ленина 1", True),
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
# word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город
# Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске):
("Первоуральск, Екатеринбургское шоссе, 5", True),
# расширенный gazetteer (deep-review fast-follow):
("Верхняя Пышма, ул. Ленина 5", True),
("Сухой Лог, ул. Мира 3", True),
],
)
def test_names_non_ekb_city(address: str, expected: bool) -> None:
assert _names_non_ekb_city(address) is expected
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
async def test_nominatim_query_prefers_tight_ekb_over_oblast_rank0() -> None:
"""rank0 — Серов (внутри OBLAST66, вне tight), rank1 — ЕКБ (tight).
Two-pass tie-break должен выбрать rank1 (tight), а не rank0, несмотря на то
что Nominatim ранжировал его вторым.
"""
payload = [
{"lat": "59.6297", "lon": "60.5541", "address": {"state": "Свердловская область"}},
{"lat": "56.838", "lon": "60.605", "address": {"state": "Свердловская область"}},
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "Серова 27")
assert item is not None
assert float(item["lat"]) == pytest.approx(56.838)
assert float(item["lon"]) == pytest.approx(60.605)
async def test_nominatim_query_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (координатно внутри OBLAST66, но регион другой)
region cross-check отсекает его None (не просто "первый как есть")."""
payload = [{"lat": "57.1522", "lon": "65.5272", "address": {"state": "Тюменская область"}}]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "какой-то мусорный адрес")
assert item is None
async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
"""Нет tight-совпадения, `address.state` отсутствует (Nominatim не всегда его
отдаёт) fallback на oblast-bbox (Серов), region cross-check не блокирует."""
payload = [{"lat": "59.6297", "lon": "60.5541"}]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "Серов, Ленина 1")
assert item is not None
assert float(item["lat"]) == pytest.approx(59.6297)
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _yandex_client_factory(transport: httpx.MockTransport):
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
meta: dict = {"precision": "exact", "text": "тест"}
if admin_area is not None:
meta["AddressDetails"] = {
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
}
return {
"GeoObject": {
"Point": {"pos": f"{lon} {lat}"},
"metaDataProperty": {"GeocoderMetaData": meta},
}
}
def _yandex_payload(members: list[dict]) -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
members = [
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Серова 27", "fake-key")
assert result is not None
assert result.lat == pytest.approx(56.838)
assert result.lon == pytest.approx(60.605)
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
region cross-check отсекает даже в ultimate as-is fallback None."""
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
assert result is None

View file

@ -26,6 +26,7 @@ sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402
GeocodeResult,
GeocodeSuggestion,
_cadastral_house_match,
_parse_street_house,
@ -264,6 +265,50 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
mock_forward.assert_called_once()
# ── geocode() non-EKB city gate (#11) ────────────────────────────────────────
async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
"""Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/
cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11).
Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через
geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила.
"""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=57.905,
lon=59.950,
full_address="проспект Ленина, 1, Нижний Тагил",
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder._cache_put"),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
assert result is not None
assert result.lat == pytest.approx(57.905)
assert result.lon == pytest.approx(59.950)
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
# ── suggest() wiring ─────────────────────────────────────────────────────────
@ -328,3 +373,39 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
"""Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1
(`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls
through to external tiers, same root cause as `geocode()` (#11)."""
db = MagicMock()
nominatim_hit = [
GeocodeSuggestion(
label="проспект Ленина, 1",
full_address="проспект Ленина, 1, Нижний Тагил",
lat=57.905,
lon=59.950,
kind="house",
)
]
with (
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
return_value=nominatim_hit,
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == pytest.approx(57.905)
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()

View file

@ -16,10 +16,13 @@ from app.services.matching.conflict_resolution import (
update_canonical_fields,
)
from app.services.matching.normalize import (
EKB_CITY_TOKEN,
address_fingerprint,
has_city_token,
has_house_number,
house_number_token,
normalize_address,
resolve_city_token,
)
# ---------------------------------------------------------------------------
@ -168,6 +171,68 @@ def test_fingerprint_all_none():
assert len(fp) == 32
# ---------------------------------------------------------------------------
# has_city_token — обл.66 city-token detection (Tier-2b oblast guard)
# ---------------------------------------------------------------------------
def test_has_city_token_detects_ekb():
assert has_city_token("екатеринбург улица ленина 5") is True
def test_has_city_token_detects_multiword_oblast_city():
assert has_city_token("нижний тагил улица ленина 100") is True
assert has_city_token("каменск уральский улица мира 7") is True
def test_has_city_token_bare_street_is_false():
# The collision-prone case: a bare street+number carries no city token.
assert has_city_token("улица ленина 100") is False
assert has_city_token("уральская улица 62к1") is False
def test_has_city_token_no_false_positive_on_street_named_after_city():
# 'улица серова' must NOT match the city token 'серов' (word-bounded, not substring).
assert has_city_token("улица серова 5") is False
assert has_city_token("первоуральская улица 3") is False
def test_has_city_token_handles_empty():
assert has_city_token("") is False
assert has_city_token(None) is False
# ---------------------------------------------------------------------------
# resolve_city_token — which обл.66 city (or None); drives the Tier-2a oblast guard
# ---------------------------------------------------------------------------
def test_resolve_city_token_returns_ekb():
assert resolve_city_token("екатеринбург улица ленина 5") == EKB_CITY_TOKEN
def test_resolve_city_token_returns_non_ekb_city():
assert resolve_city_token("нижний тагил улица ленина 100") == "нижний тагил"
assert resolve_city_token("свердловская область каменск уральский улица мира 7") == (
"каменск уральский"
)
def test_resolve_city_token_bare_street_is_none():
# The dominant Avito SERP format — no city token → None (guard stays dormant).
assert resolve_city_token("улица ленина 100") is None
def test_resolve_city_token_no_false_positive_on_street_named_after_city():
assert resolve_city_token("улица серова 5") is None
assert resolve_city_token("первоуральская улица 3") is None
def test_resolve_city_token_handles_empty():
assert resolve_city_token("") is None
assert resolve_city_token(None) is None
# ---------------------------------------------------------------------------
# match_or_create_house — mock DB tier routing
# ---------------------------------------------------------------------------
@ -622,6 +687,204 @@ def test_insert_alias_executes_when_house_number_present():
db.execute.assert_called_once()
def test_insert_alias_conflict_only_rebinds_same_house():
"""Oblast guard (#2): ON CONFLICT refreshes fingerprint/source ONLY for the same
house. A different house colliding on a bare common-street normalized_address must
not rewrite the owner's fingerprint (that would downgrade a coord-bearing alias to a
coord-less one and let the next no-coord card mis-bucket via Tier 2a)."""
from app.services.matching.houses import _insert_alias
db = MagicMock()
_insert_alias(db, house_id=1, address="улица Ленина 100", fp="c" * 32, source="avito")
sql = " ".join(str(db.execute.call_args.args[0]).split())
assert "ON CONFLICT (normalized_address) DO UPDATE" in sql
# fingerprint/source updated only when the existing owner == the incoming house.
assert "house_address_aliases.house_id = EXCLUDED.house_id" in sql
# ---------------------------------------------------------------------------
# match_or_create_house — Tier-2b oblast geo/city guard (bug #2)
# ---------------------------------------------------------------------------
def test_tier2b_coord_present_uses_geo_guard_and_matches_within_radius():
"""Coords present → Tier-2b runs the ST_DWithin JOIN guard; a house within the
radius is accepted as a fingerprint match (0.9)."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss (Tier 1)
None, # fingerprint miss (Tier 2a)
{"house_id": 77}, # Tier 2b JOIN+ST_DWithin hit (same place, within radius)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-geo", address="улица Ленина 100", lat=57.910, lon=59.980
)
assert (house_id, conf, method) == (77, 0.9, "fingerprint")
# The Tier-2b lookup must be the geo-guarded JOIN, not a bare normalized_address SELECT.
tier2b_sql = _executed_sqls(db)[3]
assert "ST_DWithin" in tier2b_sql
assert "JOIN houses" in tier2b_sql
def test_tier2b_coord_present_cross_city_falls_through_to_new():
"""A bare street+number oblast card WITH coords whose only same-street alias is a
far-away (cross-city) house: the ST_DWithin guard filters it out (JOIN miss) the
card falls through to a New house instead of mis-bucketing into the ЕКБ house."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
None, # Tier 2b JOIN+ST_DWithin miss (candidate house is >3km away)
None, # geo miss (Tier 3 — coords present)
{"id": 909}, # INSERT RETURNING id (New house)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-xcity", address="улица Ленина 100", lat=57.910, lon=59.980
)
assert (house_id, conf, method) == (909, 1.0, "new")
def test_tier2b_no_coords_no_city_token_skips_and_creates_new():
"""No coords AND a bare common-street (no city token) → Tier-2b is SKIPPED entirely
(no normalized_address lookup). The card falls through to a New house conservative:
do not mis-bucket a bare 'улица ленина 100' into a same-named ЕКБ alias."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a); Tier 2b SKIPPED (no coords, no city token)
{"id": 910}, # INSERT RETURNING id (New house) — no Tier 3 (no coords)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-bare", address="улица Ленина 100"
)
assert (house_id, conf, method) == (910, 1.0, "new")
assert not any(
"normalized_address = :na" in s for s in _executed_sqls(db)
), "bare common-street with no coords/city must not run a Tier-2b lookup"
def test_tier2b_no_coords_with_city_token_matches():
"""No coords but the normalized_address carries a city token → self-disambiguating,
so Tier-2b matches on normalized_address alone (0.9). ЕКБ cross-source dedup for
city-qualified addresses is preserved."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
None, # fingerprint miss (Tier 2a)
{"house_id": 33}, # Tier 2b normalized_address hit (city token present)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2b-city", address="Екатеринбург, улица Ленина, 100"
)
assert (house_id, conf, method) == (33, 0.9, "fingerprint")
tier2b_sql = _executed_sqls(db)[3]
assert "normalized_address = :na" in tier2b_sql
assert "ST_DWithin" not in tier2b_sql
# ---------------------------------------------------------------------------
# match_or_create_house — Tier-2a coord-less oblast city guard (#2500 follow-up)
# ---------------------------------------------------------------------------
def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new():
"""A coord-less card whose address resolves a NON-ЕКБ city (Н.Тагил) must NOT match a
globally-unique alias by fingerprint OR normalized_address (either would corrupt ЕКБ
house data). The guard skips both alias lookups New house."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss (Tier 1)
# Tier 2a (fingerprint) SKIPPED, Tier 2b (normalized_address) SKIPPED,
# Tier 3 SKIPPED (no coords) — guard forces fall-through to New.
{"id": 701}, # INSERT RETURNING id (New house)
None, # _upsert_house_source
None, # _insert_alias (address carries a house number → executes)
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2a-nt", address="Нижний Тагил, ул. Ленина, 100"
)
assert (house_id, conf, method) == (701, 1.0, "new")
sqls = _executed_sqls(db)
assert not any(
"fingerprint = :fp" in s for s in sqls
), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup"
assert not any(
"normalized_address = :na" in s for s in sqls
), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup"
def test_tier2a_coord_less_ekb_city_still_matches():
"""EKB happy-path: a coord-less card that resolves ЕКБ runs Tier-2a EXACTLY as before —
the guard does not fire (екатеринбург == EKB_CITY_TOKEN)."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"house_id": 88}, # Tier 2a fingerprint HIT (guard did NOT skip it)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5"
)
assert (house_id, conf, method) == (88, 0.9, "fingerprint")
assert any(
"fingerprint = :fp" in s for s in _executed_sqls(db)
), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup"
def test_tier2a_coord_less_bare_street_still_runs_tier2a():
"""EKB happy-path (dominant): a coord-less BARE card (no city token — today's Avito SERP
format) resolves to None guard dormant Tier-2a fingerprint dedup UNCHANGED."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"house_id": 99}, # Tier 2a fingerprint HIT (bare card → guard did not skip)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2a-bare", address="ул. Ленина, 5"
)
assert (house_id, conf, method) == (99, 0.9, "fingerprint")
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
# ---------------------------------------------------------------------------
# match_or_create_listing — mock DB tier routing
# ---------------------------------------------------------------------------

View file

@ -122,7 +122,10 @@ def test_insufficient_data_page_contains_address_and_report_num() -> None:
est = _zero_estimate()
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
assert "Ленина" in html # address
assert "EKБ-" in html # report number prefix
# #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан
# → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта).
assert "МЕРА-" in html
assert "EKБ-" not in html # старый mixed-script хардкод убран
def test_generate_pdf_insufficient_skips_offer_page() -> None:
@ -197,20 +200,23 @@ def test_cover_disclaimer_absent_when_none() -> None:
def test_cover_renders_logo_img_when_valid_url() -> None:
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA)
assert "<img" in html
assert "gk-praktika.ru" in html
# #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
# ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
# печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
# trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
# через _page_header, не через _build_cover (симметрично уже существовавшему
# паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "<img" in header_html
assert "gk-praktika.ru" in header_html
def test_cover_falls_back_to_name_when_logo_none() -> None:
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
assert "TRADE-IN" in html.upper()
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
assert "TRADE-IN" in header_html.upper()
assert "<img" not in header_html

View file

@ -278,3 +278,45 @@ async def test_fetch_serp_html_routes_through_browser() -> None:
assert html == "<html><body>real serp listing</body></html>"
scraper._browser.fetch.assert_awaited_once()
# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).
@pytest.mark.asyncio
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
"""httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
exception. It's classified transient (status=0, no soft-ban keyword match)
and, once the bounded retry budget is exhausted, surfaces as
AvitoRateLimitedError the same graceful banned-path a sidecar
HTTPStatusError takes never as a bare/generic exception."""
from unittest.mock import patch
import httpx
from scraper_kit.avito_exceptions import AvitoRateLimitedError
from scraper_kit.providers.avito import serp as serp_module
from scraper_kit.providers.avito.serp import AvitoScraper
from app.services.scraper_adapters import RealScraperConfig
scraper = AvitoScraper(RealScraperConfig())
assert scraper._cffi is None
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
with pytest.raises(AvitoRateLimitedError):
await scraper._fetch_serp_html(
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
)
# 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
# no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
assert scraper._browser.fetch.await_count == 3

View file

@ -44,6 +44,9 @@ def _reset_auth_cache() -> None:
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# MIRROR of app.main (#R2-H3): keep in sync with the real rbac_guard.
_EXTERNAL_PREFIX = "/trade-in"
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
def _build_test_app() -> FastAPI:
@ -76,6 +79,18 @@ def _build_test_app() -> FastAPI:
status_code=403,
content={"detail": "admin only"},
)
# #R2-H3 mirror: enforce roles.yaml scope on non-bootstrap paths.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
try:
allowed = auth_mod.is_path_allowed(role, external_path)
except Exception:
allowed = True
if not allowed:
return JSONResponse(
status_code=403,
content={"detail": "forbidden for role"},
)
return await call_next(request)
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
@ -84,6 +99,18 @@ def _build_test_app() -> FastAPI:
async def admin_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/search")
async def search_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/trade-in/dummy")
async def tradein_dummy() -> dict:
return {"ok": True}
@app.get("/api/v1/brand/dummy")
async def brand_dummy() -> dict:
return {"ok": True}
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@ -286,6 +313,50 @@ def test_rbac_guard_unknown_user_blocked_on_non_admin_path(client: TestClient) -
assert "not in roles config" in resp.json()["detail"].lower()
# --- #R2-H3: roles.yaml scope enforced on ALL non-admin paths (not just /admin/*) ---
def test_rbac_expired_denied_on_non_admin_api(client: TestClient) -> None:
"""#R2-H3: revoked (role=expired, paths:[] deny:/**) НЕ достаёт non-admin API.
Раньше rbac_guard гейтил только /admin/* expired имел полный non-admin доступ
(POST /search экспорт листингов и т.д.). Теперь scope-blocked 403."""
for user in ("praktika",): # role=expired (trial отозван; user2 восстановлен 2026-07-13)
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
resp = client.get(path, headers={"X-Authenticated-User": user})
assert resp.status_code == 403, f"{user} {path}: {resp.status_code}"
assert "forbidden for role" in resp.json()["detail"].lower()
def test_rbac_expired_allowed_on_bootstrap(client: TestClient) -> None:
"""expired ДОЛЖЕН достучаться до /me (получить role=expired → trial-экран) и
/brand/* (брендинг trial-экрана) иначе UX сломан. Bootstrap-исключение."""
resp_me = client.get("/api/v1/me", headers={"X-Authenticated-User": "praktika"})
assert resp_me.status_code == 200, resp_me.text
assert resp_me.json()["role"] == "expired"
resp_brand = client.get("/api/v1/brand/dummy", headers={"X-Authenticated-User": "praktika"})
assert resp_brand.status_code == 200, resp_brand.text
def test_rbac_pilot_allowed_on_tradein_api(client: TestClient) -> None:
"""pilot по roles.yaml имеет /trade-in/api/v1/** → доступ к trade-in/search сохраняется."""
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
assert resp.status_code == 200, f"pilot {path}: {resp.text}"
def test_rbac_admin_allowed_everywhere(client: TestClient) -> None:
for path in ("/api/v1/search", "/api/v1/trade-in/dummy", "/api/v1/admin/dummy"):
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200, f"admin {path}: {resp.text}"
def test_rbac_analyst_allowed_non_admin_denied_admin(client: TestClient) -> None:
ok = client.get("/api/v1/search", headers={"X-Authenticated-User": "analysttest"})
assert ok.status_code == 200, ok.text
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
assert denied.status_code == 403
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
"""Любой non-public path без X-Authenticated-User → 401."""
resp = client.get("/api/v1/me")

View file

@ -0,0 +1,140 @@
"""Tests for RequestAuditMiddleware (app/core/request_audit.py).
Тестируем middleware в изоляции на минимальном FastAPI-приложении (как
tests/test_ratelimit.py) не тянем тяжёлый app.main. schedule_event
и should_log_login мокируются, реальная запись в user_events не требуется
(она уже покрыта tests/test_user_events.py).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.request_audit import RequestAuditMiddleware
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return TestClient(app)
def test_authenticated_api_request_schedules_api_request_event(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_called_once()
kwargs = mock_schedule.call_args.kwargs
assert kwargs["event_type"] == "api_request"
assert kwargs["username"] == "alice"
assert kwargs["path"] == "/api/v1/ping"
assert kwargs["method"] == "GET"
def test_login_event_scheduled_when_should_log_login_true(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["api_request", "login"]
def test_login_event_not_scheduled_when_should_log_login_false(client: TestClient) -> None:
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=False),
):
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["api_request"]
def test_no_username_header_skips_audit(client: TestClient) -> None:
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = client.get("/api/v1/ping")
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_public_path_skips_audit_even_with_username(client: TestClient) -> None:
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = client.get("/health", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_non_api_path_skips_audit(client: TestClient) -> None:
"""Path not under /api/ (and not one of the FastAPI-generated docs paths) is skipped."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/other")
def other() -> dict[str, bool]:
return {"ok": True}
with patch("app.core.request_audit.schedule_event") as mock_schedule:
resp = TestClient(app).get("/other", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
mock_schedule.assert_not_called()
def test_audit_failure_does_not_break_response(client: TestClient) -> None:
"""A raising schedule_event must never turn a 200 into a 500 (dispatch swallows it)."""
with patch("app.core.request_audit.schedule_event", side_effect=RuntimeError("boom")):
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
"""/api/v1/admin/* — ops-действия (просмотр дашбордов аудита/аналитики), не
поведение пилота: api_request НЕ пишем (иначе дашборд зашумляет активность),
а login (вход админа с IP) остаётся валидным аудитом."""
app = FastAPI()
app.add_middleware(RequestAuditMiddleware)
@app.get("/api/v1/admin/analytics")
def analytics() -> dict[str, bool]:
return {"ok": True}
with (
patch("app.core.request_audit.schedule_event") as mock_schedule,
patch("app.core.request_audit.should_log_login", return_value=True),
):
resp = TestClient(app).get(
"/api/v1/admin/analytics", headers={"X-Authenticated-User": "admin"}
)
assert resp.status_code == 200
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
assert event_types == ["login"]

View file

@ -7,6 +7,7 @@
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource),
плюс содержимое backfill-миграции 077 без живого FDW / DB-фикстуры.
"""
import inspect
import os
from pathlib import Path
@ -45,9 +46,67 @@ def test_import_populates_source_id() -> None:
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
def test_import_keeps_on_conflict_dedup_hash() -> None:
"""Дедуп по-прежнему через ON CONFLICT (dedup_hash) DO NOTHING."""
assert "ON CONFLICT (dedup_hash) DO NOTHING" in _IMPORT_SRC
def test_import_uses_on_conflict_do_update_for_corrections() -> None:
"""Fix D: дедуп по dedup_hash, но ON CONFLICT DO UPDATE (не DO NOTHING) —
исправленный/переопубликованный квартал обновляет сырые факты Росреестра.
"""
assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC
assert "DO NOTHING" not in _IMPORT_BODY
# Обновляются изменяемые сырые факты...
for col in (
"address = EXCLUDED.address",
"region_code = EXCLUDED.region_code",
"city = EXCLUDED.city",
"rooms = EXCLUDED.rooms",
"area_m2 = EXCLUDED.area_m2",
"floor = EXCLUDED.floor",
"year_built = EXCLUDED.year_built",
"price_rub = EXCLUDED.price_rub",
"price_per_m2 = EXCLUDED.price_per_m2",
"deal_date = EXCLUDED.deal_date",
):
assert col in _IMPORT_SRC, f"missing DO UPDATE SET column: {col!r}"
# ...но идемпотентность через IS DISTINCT FROM (неизменные строки не трогаются).
assert "IS DISTINCT FROM EXCLUDED.price_rub" in _IMPORT_SRC
# xmax-дискриминатор insert vs update.
assert "RETURNING (xmax = 0)" in _IMPORT_SRC
def test_import_do_update_never_overwrites_enrichment_columns() -> None:
"""Fix D: обогащение (lat/lon/geom/geocode/cadastral_number...) не в SET-списке.
Эти колонки заполняются пост-импортом (geocode_deals_*) повторный импорт не
должен их затирать. Отсутствие в SET DO UPDATE гарантирует сохранность.
"""
conflict_clause = _IMPORT_SRC.split("ON CONFLICT (dedup_hash) DO UPDATE SET", 1)[1]
set_and_where = conflict_clause.split("RETURNING", 1)[0]
for enrich in (
"lat =",
"lon =",
"geom =",
"geocode_tried_at =",
"cadastral_number =",
"total_floors =",
"house_type =",
):
assert enrich not in set_and_where, f"enrichment column leaked into UPDATE: {enrich!r}"
def test_import_separates_errors_from_dedup_skips() -> None:
"""Fix C: INSERT-ошибки считаются отдельным счётчиком, не как dedup-skip."""
assert '"rows_errored": 0' in _IMPORT_SRC
assert '"rows_updated": 0' in _IMPORT_SRC
assert "batch_errored += 1" in _IMPORT_SRC
# Per-row except-ветка (после лога "INSERT failed", до конца батча) инкрементит
# ТОЛЬКО errored — больше НЕ skip (это был баг silent-green: ошибки маскировались
# под дедуп).
per_row_except = _IMPORT_SRC.split("INSERT failed", 1)[1].split("last_id = batch_max_id", 1)[0]
assert "batch_errored += 1" in per_row_except
assert "batch_skipped += 1" not in per_row_except
# Порог ошибок → run failed, а не silent-green.
assert "DKP_IMPORT_ERROR_RATE_THRESHOLD" in _IMPORT_SRC
assert "marking run failed" in _IMPORT_SRC
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
@ -89,12 +148,18 @@ def test_migration_077_converts_md5_to_plain_key() -> None:
assert "BEGIN;" in sql and "COMMIT;" in sql
def test_migration_077_filter_matches_live_import() -> None:
"""Фильтр src CTE байт-в-байт совпадает с живым импортом → все строки сконвертируются."""
def test_migration_077_shared_filter_matches_live_import() -> None:
"""Дедуп-релевантные фильтры src CTE миграции 077 совпадают с живым импортом.
Исключение city ILIKE (см. test_live_import_dropped_ekb_city_filter ниже):
077 backfill'ил ЕКБ-строки под EKB-only scope того времени; живой импорт расширен
на всю Свердловскую область (region_code=66, все города), поэтому city-фильтр из
живого импорта СНЯТ намеренно. Остальные клозы обязаны совпадать байт-в-байт,
иначе 077 конвертировал бы не тот набор строк.
"""
sql = _MIGRATION_077.read_text("utf-8")
for clause in (
"region_code = 66",
"city ILIKE '%катеринбург%'",
"realestate_type_code = '002001003000'",
"area BETWEEN 18 AND 200",
"deal_price BETWEEN 1000000 AND 100000000",
@ -103,3 +168,17 @@ def test_migration_077_filter_matches_live_import() -> None:
):
assert clause in sql, f"missing filter clause in migration: {clause!r}"
assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}"
def test_live_import_dropped_ekb_city_filter() -> None:
"""Живой импорт БОЛЬШЕ не фильтрует по городу — Mera расширена на всю обл. 66.
Историческая миграция 077 (уже применена на прод, трогать нельзя) сохраняет свой
EKB-only city-фильтр; живой импорт нет. Guard против случайного возврата
ЕКБ-скоупа, который снова отрезал бы +47k не-ЕКБ сделок.
"""
sql = _MIGRATION_077.read_text("utf-8")
# 077 — исторический EKB-scope, city-фильтр там остаётся неизменным.
assert "city ILIKE '%катеринбург%'" in sql
# Живой импорт city-фильтр снял намеренно (region_code=66 = вся область).
assert "ILIKE '%катеринбург%'" not in _IMPORT_SRC

View file

@ -3,10 +3,17 @@
Coverage:
(a) _next_quarter: boundary cases (Q4 Q1 of next year, mid-year).
(b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
(c) poll_rosreestr_new_quarter available=True path (mocked httpx + fake DB).
(d) poll_rosreestr_new_quarter available=False path.
(e) Network error available=False (never raises into caller).
(f) No DB data fallback baseline 2025Q4, checks 2026Q1.
(c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
(d) check_new_quarter_available new /data-sets/ autoindex flow:
- real application/zip + large Content-Length True
- soft-404 regression: file found but Content-Type text/html False
(this is the exact bug that broke the old /opendata/ flat-URL poller)
- tiny Content-Length (placeholder/error file) False
- quarter folder missing from autoindex False (not yet published)
- dataset file missing from quarter folder False
- network error / timeout False, never raises
(e) poll_rosreestr_new_quarter orchestration: available=True/False end-to-end,
no DB data fallback, network error propagation.
"""
from __future__ import annotations
@ -15,6 +22,7 @@ import os
import sys
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import quote
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@ -33,6 +41,46 @@ from app.services.rosreestr_poll import ( # noqa: E402
rosreestr_dataset_url,
)
# ---------------------------------------------------------------------------
# cp1251 href fixtures — built the same way the real portal encodes folder/file
# names (percent-encoded cp1251 bytes), so tests exercise the actual decode path.
# ---------------------------------------------------------------------------
def _cp1251_href(name: str) -> str:
return quote(name.encode("cp1251"), safe="")
def _autoindex_html(*folder_names: str) -> str:
"""Build a fake Apache-autoindex-style HTML listing for /data-sets/."""
links = ['<a href="../">../</a>']
for name in folder_names:
href = _cp1251_href(name) + "/"
links.append(f'<a href="{href}">{name}/</a>')
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
def _folder_html(*file_names: str) -> str:
"""Build a fake Apache-autoindex-style HTML listing for a quarter folder."""
links = ['<a href="../">../</a>']
for name in file_names:
href = _cp1251_href(name)
links.append(f'<a href="{href}">{name}</a>')
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
_SDELKI_FILE_2026_Q1 = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv.zip"
_KADASTR_FILE_2026_Q1 = "dataset_КАДАСТРСТОИМОСТЬ_r-r_01-92_y_2026_q_1.csv.zip"
def _mock_response(*, status_code: int = 200, text: str = "", headers: dict | None = None):
resp = MagicMock()
resp.status_code = status_code
resp.text = text
resp.headers = headers or {}
return resp
# ---------------------------------------------------------------------------
# (a) _next_quarter
# ---------------------------------------------------------------------------
@ -62,10 +110,25 @@ def test_period_start_to_quarter_from_string() -> None:
assert _period_start_to_quarter("2026-07-01") == (2026, 3)
# ---------------------------------------------------------------------------
# (c) rosreestr_dataset_url — cp1251 percent-encoding must match the real portal.
# ---------------------------------------------------------------------------
def test_rosreestr_dataset_url_matches_verified_real_encoding() -> None:
"""Encoding must byte-match the live-verified rosreestr.gov.ru/data-sets/ URL."""
url = rosreestr_dataset_url(2026, 1)
assert url == (
"https://rosreestr.gov.ru/data-sets/"
"1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./"
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"
)
def test_rosreestr_dataset_url_contains_year_quarter() -> None:
url = rosreestr_dataset_url(2026, 2)
assert "2026" in url
assert "_2" in url or "q_2" in url # quarter embedded
assert "q_2" in url
# ---------------------------------------------------------------------------
@ -125,22 +188,181 @@ def test_latest_loaded_quarter_returns_none_when_empty() -> None:
# ---------------------------------------------------------------------------
# (c) poll_rosreestr_new_quarter — available=True
# (d) check_new_quarter_available — /data-sets/ autoindex flow
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None:
"""If latest loaded is Q1 2026 and head returns 200, returns available=True for Q2 2026."""
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026
mock_response = MagicMock()
mock_response.status_code = 200
async def test_check_available_true_for_real_zip() -> None:
"""Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.head.return_value = mock_response
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
headers={"content-type": "application/zip", "content-length": "9500000"}
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is True
assert mock_client.get.call_count == 2
assert mock_client.head.call_count == 1
@pytest.mark.asyncio
async def test_check_available_false_soft_404_text_html() -> None:
"""Regression guard: HTTP 200 with Content-Type text/html (Bitrix soft-404) → False.
This is the exact failure mode that broke the old /opendata/ flat-URL poller
it treated bare HTTP 200 as availability. Even if a matching file href is found,
a non-zip Content-Type must never be treated as available.
"""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
status_code=200,
headers={"content-type": "text/html; charset=utf-8", "content-length": "1234"},
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_available_false_tiny_content_length() -> None:
"""application/zip but tiny Content-Length (placeholder/error file) → False."""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = _mock_response(
headers={"content-type": "application/zip", "content-length": "512"}
)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_available_false_quarter_folder_missing() -> None:
"""Autoindex doesn't have the target quarter folder yet → False, no further GET/HEAD."""
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not published yet
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(text=index_html)
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
assert mock_client.get.call_count == 1 # never fetched a (non-existent) folder
mock_client.head.assert_not_called()
@pytest.mark.asyncio
async def test_check_available_false_dataset_file_missing_in_folder() -> None:
"""Quarter folder exists but has no dataset_СДЕЛКИ file (only КАДАСТРСТОИМОСТЬ) → False."""
index_html = _autoindex_html("1 квартал 2026г.")
folder_html = _folder_html(_KADASTR_FILE_2026_Q1) # no СДЕЛКИ file
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
mock_client.head.assert_not_called()
@pytest.mark.asyncio
async def test_check_available_false_on_index_404() -> None:
"""Unexpected non-200 on the base autoindex → False, no raise."""
mock_client = AsyncMock()
mock_client.get.return_value = _mock_response(status_code=404, text="")
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_timeout_returns_false() -> None:
"""Timeout → returns False, no exception."""
import httpx
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.TimeoutException("timeout")
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
assert result is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_network_error_returns_false() -> None:
"""Network error mid-flow (folder GET) → returns False, no exception."""
import httpx
index_html = _autoindex_html("1 квартал 2026г.")
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
httpx.ConnectError("simulated network error"),
]
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
assert result is False
# ---------------------------------------------------------------------------
# (e) poll_rosreestr_new_quarter — orchestration
# ---------------------------------------------------------------------------
def _patched_client_for(index_html: str, folder_html: str, head_response) -> AsyncMock:
mock_client = AsyncMock()
mock_client.get.side_effect = [
_mock_response(text=index_html),
_mock_response(text=folder_html),
]
mock_client.head.return_value = head_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
return mock_client
@pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None:
"""If latest loaded is Q1 2026 and the Q2 2026 dataset is a real zip, available=True."""
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 → next = Q2 2026
index_html = _autoindex_html("2 квартал 2026г.")
file_name = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv.zip"
folder_html = _folder_html(file_name)
head_response = _mock_response(
headers={"content-type": "application/zip", "content-length": "9500000"}
)
mock_client = _patched_client_for(index_html, folder_html, head_response)
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
@ -152,21 +374,15 @@ async def test_poll_available_true_returns_correct_next_quarter() -> None:
assert result["latest_loaded_quarter"] == 1
# ---------------------------------------------------------------------------
# (d) poll_rosreestr_new_quarter — available=False (404)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_poll_available_false_on_404() -> None:
"""404 from server → available=False."""
db = _FakeDB(date(2026, 3, 15)) # Q1 2026
async def test_poll_available_false_when_folder_not_published() -> None:
"""Quarter folder not yet published (autoindex has no matching entry) → available=False."""
db = _FakeDB(date(2026, 3, 15)) # Q1 2026 → next = Q2 2026
mock_response = MagicMock()
mock_response.status_code = 404
index_html = _autoindex_html("1 квартал 2026г.") # Q2 2026 missing
mock_client = AsyncMock()
mock_client.head.return_value = mock_response
mock_client.get.return_value = _mock_response(text=index_html)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
@ -178,11 +394,6 @@ async def test_poll_available_false_on_404() -> None:
assert result["quarter"] == 2
# ---------------------------------------------------------------------------
# (e) Network error → available=False (never raises into caller)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_poll_network_error_returns_false_no_raise() -> None:
"""Network error → available=False, no exception propagated."""
@ -191,7 +402,7 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
db = _FakeDB(date(2026, 1, 20)) # Q1 2026
mock_client = AsyncMock()
mock_client.head.side_effect = httpx.ConnectError("simulated network error")
mock_client.get.side_effect = httpx.ConnectError("simulated network error")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
@ -202,18 +413,6 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
assert result["available"] is False
@pytest.mark.asyncio
async def test_check_new_quarter_available_timeout_returns_false() -> None:
"""Timeout → returns False, no exception."""
import httpx
mock_client = AsyncMock()
mock_client.head.side_effect = httpx.TimeoutException("timeout")
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
assert result is False
# ---------------------------------------------------------------------------
# (f) No DB data → fallback baseline checks 2026Q1
# ---------------------------------------------------------------------------
@ -224,11 +423,10 @@ async def test_poll_fallback_when_no_db_data() -> None:
"""No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
db = _FakeDB(None)
mock_response = MagicMock()
mock_response.status_code = 404 # not yet available
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not yet published
mock_client = AsyncMock()
mock_client.head.return_value = mock_response
mock_client.get.return_value = _mock_response(text=index_html)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
@ -238,6 +436,7 @@ async def test_poll_fallback_when_no_db_data() -> None:
# Fallback: 2025Q4 → check 2026Q1
assert result["year"] == 2026
assert result["quarter"] == 1
assert result["available"] is False
# latest_loaded_year/quarter should be None (no DB data)
assert result["latest_loaded_year"] is None
assert result["latest_loaded_quarter"] is None

View file

@ -426,7 +426,6 @@ def _run_estimate(
*,
anchor_comps: list[dict[str, Any]],
anchor_tier: str | None,
flag_enabled: bool = True,
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
payload=None,
radius_analogs: list[dict[str, Any]] | None = None,
@ -439,7 +438,6 @@ def _run_estimate(
ST_DWithin не нашёл радиусных аналогов (median=0) якорь обязан сработать
по same-building комплам независимо.
"""
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
@ -448,7 +446,6 @@ def _run_estimate(
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
patch(
"app.services.estimator.geocode",
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
@ -546,14 +543,6 @@ def test_estimate_economy_no_regression() -> None:
assert est.median_price_per_m2 >= 100_000
def test_estimate_flag_off_unchanged_radius_result() -> None:
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
assert est_off.median_price_per_m2 == 210_000
assert est_off.median_price_rub == int(210_000 * 146.2)
def test_estimate_expected_sold_distinct_after_anchor() -> None:
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
expected_sold = headline × per-rooms ratio DISTINCT, строго ниже median.

View file

@ -0,0 +1,227 @@
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
Покрывает:
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
- fresh: age <= max_age_days (алерта нет);
- stale: age > max_age_days (алерт);
- граница порога (== max_age_days нет алерта; +1 день алерт).
2. check_sber_freshness с FakeDB (fresh / stale / emptymark_failed / кастомный lag).
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
"""
from __future__ import annotations
import os
import re
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.core.config import settings
from app.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
_MAY_2026 = date(2026, 5, 1)
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
def _now(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, tzinfo=UTC)
def test_fresh_within_max_age() -> None:
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
assert v.stale is False
assert v.age_days == 30
assert v.latest_period == _MAY_2026
def test_stale_beyond_max_age() -> None:
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
assert v.stale is True
assert v.age_days == 72
def test_threshold_boundary_exact_no_alert() -> None:
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
assert v.age_days == 60
assert v.stale is False
def test_threshold_boundary_next_day_alert() -> None:
"""Порог + 1 день (age=61) — первый алерт."""
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
assert v.age_days == 61
assert v.stale is True
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
class _Row:
def __init__(self, latest: date | None) -> None:
self.latest = latest
class _FakeResult:
def __init__(self, latest: date | None) -> None:
self._latest = latest
def first(self) -> _Row:
return _Row(self._latest)
class _FakeDB:
def __init__(self, latest: date | None) -> None:
self._latest = latest
self.rolled_back = False
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
return _FakeResult(self._latest)
def rollback(self) -> None:
self.rolled_back = True
def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
calls: dict[str, Any] = {"done": None, "failed": None, "heartbeat": 0}
monkeypatch.setattr(
mon.runs_mod,
"update_heartbeat",
lambda *a, **k: calls.__setitem__("heartbeat", calls["heartbeat"] + 1),
)
monkeypatch.setattr(
mon.runs_mod,
"mark_done",
lambda _db, run_id, counters: calls.__setitem__("done", dict(counters)),
)
monkeypatch.setattr(
mon.runs_mod,
"mark_failed",
lambda _db, run_id, err, counters: calls.__setitem__("failed", err),
)
return calls
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
assert calls["done"] == out
assert calls["failed"] is None
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# @2026-07-12 (прод): age=72 > 60 → алерт.
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 1
assert out["age_days"] == 72
assert out["latest_month"] == 5
# Монитор НЕ падает при алерте — прогон done, а не failed.
assert calls["done"] == out
assert calls["failed"] is None
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _patch_runs(monkeypatch)
db = _FakeDB(None)
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
assert out["alert"] == 0
assert calls["done"] is None
assert calls["failed"] is not None
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
out = mon.check_sber_freshness(
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
) # type: ignore[arg-type]
assert out["alert"] == 1
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
_patch_runs(monkeypatch)
db = _FakeDB(_MAY_2026)
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
assert out_exact["age_days"] == threshold
assert out_exact["alert"] == 0
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
assert out_over["alert"] == 1
# ── Миграция 180 ──────────────────────────────────────────────────────────────
def test_migration_180_exists() -> None:
assert _MIGRATION_180.is_file(), f"missing migration: {_MIGRATION_180}"
def test_migration_180_seeds_source() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "'sber_freshness_monitor'" in sql
def test_migration_180_enabled_true() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "true" in sql
def test_migration_180_is_idempotent() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "ON CONFLICT (source) DO NOTHING" in sql
def test_migration_180_is_transactional() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_180_window_9_to_10_utc() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert re.search(r"\b9\b", sql), "window_start_hour 9 missing"
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
def test_migration_180_lag_allowance_25() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert "lag_allowance_days" in sql
assert "25" in sql
def test_migration_180_no_psycopg_trap() -> None:
sql = _MIGRATION_180.read_text("utf-8")
assert not re.search(r":\w+::", sql)
# ── Регистрация в kit registry ─────────────────────────────────────────────────
def test_kit_product_handler_registered() -> None:
# ctx не нужен для сборки dict ключей — build_product_handlers его не замыкает.
handlers = build_product_handlers(ctx=None) # type: ignore[arg-type]
assert "sber_freshness_monitor" in handlers

View file

@ -28,11 +28,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import scheduler as kit_sched
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
from scraper_kit.orchestration.scheduler import (
Handler,
SchedulerContext,
_claim_run,
_dispatch,
_job_avito_city_sweep,
_job_cian_city_sweep,
_job_yandex_city_sweep,
build_registry,
reap_zombies,
reschedule_after_minutes,
@ -74,14 +78,20 @@ _PRODUCT_SOURCES: set[str] = {
"proxy_healthcheck",
}
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно.
# "*_city_sweep_*" — wildcard-семья oblast B1 rollout (region 66, dormant, #179):
# per-city source (avito_city_sweep_nizhniy_tagil и т.п.) резолвится на тот же
# handler, что EKB source, через resolve_handler-префикс (см. _default_kit_handlers).
_KIT_NATIVE_SOURCES = {
"avito_city_sweep",
"avito_city_sweep_*",
"avito_full_load",
"avito_full_load_exhaustive",
"avito_newbuilding_sweep",
"yandex_city_sweep",
"yandex_city_sweep_*",
"cian_city_sweep",
"cian_city_sweep_*",
"cian_full_load",
"domclick_city_sweep",
}
@ -156,7 +166,10 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
def test_kit_native_handler_set() -> None:
"""Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно."""
"""Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast
"*_city_sweep_*" wildcard, #179).
"""
registry = build_registry()
assert set(registry) == _KIT_NATIVE_SOURCES
for src in _KIT_NATIVE_SOURCES:
@ -434,3 +447,87 @@ async def test_dispatch_post_claim_hook_runs() -> None:
await asyncio.gather(*list(ctx._inflight_tasks))
post.assert_called_once()
assert post.call_args[0][1] == 300 # run_id
# ── 6. B1 oblast rollout — city param → city anchors, wildcard dispatch ──────────
def _oblast_ctx() -> SchedulerContext:
return SchedulerContext(
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
session_factory=MagicMock(),
runs=MagicMock(),
)
async def test_job_avito_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "nizhniy_tagil"} → Н.Тагил anchors переданы в run_avito_city_sweep."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nizhniy_tagil"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["nizhniy_tagil"]
assert kwargs["anchors"] == [(57.910, 59.980, "Н.Тагил центр")]
async def test_job_cian_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "kamensk_uralskiy"} → anchors города переданы в run_cian_city_sweep."""
with patch.object(kit_sched, "run_cian_city_sweep", AsyncMock()) as mock_run:
await _job_cian_city_sweep(MagicMock(), 1, {"city": "kamensk_uralskiy"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["kamensk_uralskiy"]
async def test_job_yandex_city_sweep_with_city_param_uses_city_anchors() -> None:
"""params={"city": "serov"} → anchors города переданы в run_yandex_city_sweep."""
with patch.object(kit_sched, "run_yandex_city_sweep", AsyncMock()) as mock_run:
await _job_yandex_city_sweep(MagicMock(), 1, {"city": "serov"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] == CITY_ANCHORS["serov"]
async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> None:
"""Без city (EKB-schedule, back-compat) — anchors=None передан (run_* сам берёт EKB_ANCHORS)."""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
НЕ падает с KeyError.
"""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
registry = build_registry()
assert (
resolve_handler("avito_city_sweep_nizhniy_tagil", registry)
is registry["avito_city_sweep_*"]
)
assert resolve_handler("cian_city_sweep_serov", registry) is registry["cian_city_sweep_*"]
assert (
resolve_handler("yandex_city_sweep_pervouralsk", registry)
is registry["yandex_city_sweep_*"]
)
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]

View file

@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
Python-side gating источник содержит и флаг настройки, и hardened
secondary_count/primary_count gate (#1774, после code-review).
Python-side gating источник содержит hardened secondary_count/primary_count
gate (#1774, после code-review).
"""
src = inspect.getsource(est_mod._fetch_anchor_comps)
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
# (b) Tier A теперь gated Python-side (#1774).
assert (
"estimate_sb_tier_a_allow_primary_if_secondary_present" in src
), "Tier A no longer references the #1774 gating flag — gated relaxation missing"
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"

View file

@ -0,0 +1,104 @@
"""Per-city SERP location URL builders (#12 fix EKB-leak в oblast city-sweep).
Проблема: avito/cian/yandex провайдеры хардкодили ЕКБ-локацию (путь /ekaterinburg/,
CIAN_EKB_REGION_ID, _EKB_RGID) мимо anchor'а oblast-города → city-sweep целевого
города (напр. Н.Тагил) фактически скрейпил ЕКБ, n_analogs=0. Фикс новые
kw-параметры (avito: target_city_slug уже был #2487, теперь и в ПУТИ URL;
cian: city_region_id; yandex: city_rgid), все дефолт None ЕКБ-поведение
byte-identical прежнему (back-compat critical).
No network / no DB чистые URL-билдеры на stub-конфиге.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.serp import NOVOSTROYKA_SLUG, AvitoScraper
from scraper_kit.providers.cian.serp import CIAN_EKB_REGION_ID, CianScraper
from scraper_kit.providers.yandex.serp import _EKB_RGID, YandexRealtyScraper
_STUB_CFG = SimpleNamespace()
def _query(url: str) -> dict[str, list[str]]:
return parse_qs(urlparse(url).query)
# ── Avito: city-slug в ПУТЬ URL (не только SERP-фильтр) ────────────────────────
def test_avito_web_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
url = s._build_web_url(57.910, 59.980, radius_km=2, page=1)
assert "/nizhniy_tagil/" in url
assert "/ekaterinburg/" not in url
def test_avito_citywide_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="kamensk-uralskiy") # type: ignore[arg-type]
url = s._build_citywide_url(page=2)
assert "/kamensk-uralskiy/" in url
assert "/ekaterinburg/" not in url
def test_avito_newbuilding_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="verhnyaya_pyshma") # type: ignore[arg-type]
url = s._build_newbuilding_url(page=1)
assert "/verhnyaya_pyshma/" in url
assert "/ekaterinburg/" not in url
assert NOVOSTROYKA_SLUG in url
def test_avito_rooms_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="serov") # type: ignore[arg-type]
url = s._build_rooms_url("2k", page=1)
assert "/serov/" in url
assert "/ekaterinburg/" not in url
def test_avito_urls_default_to_ekaterinburg_when_no_target_city_slug() -> None:
"""Back-compat: target_city_slug=None → путь /ekaterinburg/ (ЕКБ full_load/pipeline)."""
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
assert "/ekaterinburg/" in s._build_web_url(56.84, 60.60, radius_km=1, page=1)
assert "/ekaterinburg/" in s._build_citywide_url(page=1)
assert "/ekaterinburg/" in s._build_newbuilding_url(page=1)
assert "/ekaterinburg/" in s._build_rooms_url("2k", page=1)
# ── Cian: city_region_id → region= (subdomain НЕ трогается) ────────────────────
def test_cian_build_url_uses_city_region_id() -> None:
s = CianScraper(_STUB_CFG, city_region_id=4886) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["region"] == ["4886"]
assert urlparse(url).hostname == "ekb.cian.ru"
def test_cian_build_url_defaults_to_ekb_region_when_no_city_region_id() -> None:
"""Back-compat: city_region_id=None → region=CIAN_EKB_REGION_ID (4743)."""
s = CianScraper(_STUB_CFG, city_region_id=None) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["region"] == [str(CIAN_EKB_REGION_ID)]
assert urlparse(url).hostname == "ekb.cian.ru"
# ── Yandex: city_rgid → rgid= ───────────────────────────────────────────────────
def test_yandex_build_url_uses_city_rgid() -> None:
s = YandexRealtyScraper(_STUB_CFG, city_rgid=328286) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["rgid"] == ["328286"]
def test_yandex_build_url_defaults_to_ekb_rgid_when_no_city_rgid() -> None:
"""Back-compat: city_rgid=None → rgid=_EKB_RGID (559132)."""
s = YandexRealtyScraper(_STUB_CFG, city_rgid=None) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["rgid"] == [str(_EKB_RGID)]

View file

@ -105,12 +105,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
@ -135,12 +130,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
assert tier is None
@ -162,42 +152,13 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
assert tier is None
assert comps == []
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
rows = [
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
_row(source="avito", source_id="a2", listing_segment=None),
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
_row(source="yandex", source_id="y2", listing_segment=None),
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
),
):
comps, tier = _fetch(db)
assert tier == "A"
# novostroyki отброшены → только 4 вторички.
assert len(comps) == 4
def test_tier_a_dedup_same_source_id_collapses() -> None:
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
@ -216,12 +177,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
@ -257,12 +213,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# source_id-primary схлопывает несмотря на разные url → 5 comps.
@ -281,12 +232,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
]
db = _db_mock(rows)
with (
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
patch.object(
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
),
):
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.

View file

@ -4,6 +4,8 @@
- happy path: consent=True + валидный телефон -> 200, INSERT + commit
- consent=False -> 422 (Literal[True] guard)
- невалидный формат телефона -> 422
- телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening)
- source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы)
- estimate_id, которого нет в trade_in_estimates -> 404
"""
@ -53,12 +55,15 @@ def _insert_result(lead_id: str) -> MagicMock:
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
from app.api.v1 import lead as lead_module
lead_id = str(uuid4())
db.execute.return_value = _insert_result(lead_id)
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+7 (912) 345-67-89", "consent": True},
headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"},
)
assert r.status_code == 200, r.text
body = r.json()
@ -71,6 +76,27 @@ def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
assert params["consent"] is True
assert params["source"] == "result"
assert params["estimate_id"] is None
# 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text
# snapshot must reach the INSERT params, not just the audit log (#2497 TODO).
assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For
assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION
assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT
def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None:
# No X-Forwarded-For header -> falls back to request.client.host (TestClient
# reports "testclient"), still persisted rather than silently None.
lead_id = str(uuid4())
db.execute.return_value = _insert_result(lead_id)
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True},
)
assert r.status_code == 200, r.text
params = db.execute.call_args.args[1]
assert params["client_ip"] == "testclient"
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
@ -121,10 +147,41 @@ def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> N
"phone": "+79123456789",
"consent": True,
"estimate_id": estimate_id,
"source": "landing",
},
)
assert r.status_code == 200, r.text
params = db.execute.call_args.args[1]
assert params["estimate_id"] == estimate_id
assert params["source"] == "landing"
assert params["source"] == "result"
def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None:
# "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы),
# но содержит 0 цифр -> должно отклоняться digit-валидатором.
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "(()) -- ..", "consent": True},
)
assert r.status_code == 422, r.text
assert not db.execute.called
def test_lead_too_few_digits_phone_422(client: TestClient, db: MagicMock) -> None:
# 6 цифр < минимума 10 -> 422 (маску проходит по длине, digit-guard режет).
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "123-456", "consent": True},
)
assert r.status_code == 422, r.text
assert not db.execute.called
def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> None:
# "landing"-воронка недостижима (rbac_guard закрывает /lead) — литерал убран
# из схемы, поэтому явный source="landing" теперь 422.
r = client.post(
"/api/v1/trade-in/lead",
json={"phone": "+79123456789", "consent": True, "source": "landing"},
)
assert r.status_code == 422, r.text
assert not db.execute.called

View file

@ -0,0 +1,292 @@
"""Tests for app.services.user_events — record_event / schedule_event / should_log_login.
Coverage:
(a) record_event inserts exactly one row via a mocked SessionLocal, commits, and the
SQL uses CAST(:x AS type) (never the psycopg-v3-broken `:x::type` form).
(b) record_event swallows exceptions a DB failure (connect or execute) never raises.
(c) schedule_event in a sync context (no running event loop) calls record_event inline.
(d) schedule_event in an async context schedules a non-blocking background task that
still calls through, and a failure inside it is logged, not raised.
(e) should_log_login: True once per (user, ip, ua, day), False on repeat; independent
per user / ip / user_agent; prunes the in-process set past capacity.
(f) optional real-DB round trip against app.core.db.SessionLocal (the exact session
record_event uses) self-skips without a reachable, non-placeholder Postgres,
mirroring tests/test_house_dedup_merge.py's live-DB pattern.
"""
from __future__ import annotations
import asyncio
import os
import re
from typing import Any
from unittest.mock import patch
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services import user_events as ue
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeResult:
def fetchone(self) -> None:
return None
class _FakeSession:
"""SessionLocal() stand-in supporting the `with SessionLocal() as db:` pattern."""
def __init__(self) -> None:
self.executed: list[tuple[str, dict[str, Any] | None]] = []
self.committed = False
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
self.executed.append((str(stmt), params))
return _FakeResult()
def commit(self) -> None:
self.committed = True
def __enter__(self) -> _FakeSession:
return self
def __exit__(self, *exc: object) -> bool:
return False
# ---------------------------------------------------------------------------
# (a) record_event — successful insert
# ---------------------------------------------------------------------------
def test_record_event_inserts_and_commits() -> None:
fake = _FakeSession()
with patch.object(ue, "SessionLocal", return_value=fake):
ue.record_event(
event_type="estimate_request",
username="user1",
ip="1.2.3.4",
user_agent="pytest-agent",
path="/api/v1/trade-in/estimate",
method="POST",
estimate_id="11111111-1111-1111-1111-111111111111",
payload={"address": "ул. Ленина, 1", "area_m2": 50.0, "rooms": 2},
)
assert fake.committed is True
assert len(fake.executed) == 1
sql, params = fake.executed[0]
assert params is not None
assert "INSERT INTO user_events" in sql
assert params["event_type"] == "estimate_request"
assert params["username"] == "user1"
assert params["ip"] == "1.2.3.4"
assert params["estimate_id"] == "11111111-1111-1111-1111-111111111111"
# payload сериализован в JSON-строку (CAST(:payload AS jsonb) на стороне SQL).
assert isinstance(params["payload"], str)
assert "Ленина" in params["payload"]
def test_record_event_cast_not_doublecolon() -> None:
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
fake = _FakeSession()
with patch.object(ue, "SessionLocal", return_value=fake):
ue.record_event(event_type="login", username="user1")
sql, _ = fake.executed[0]
assert not re.search(r":\w+::\w", sql)
assert "CAST(:ip AS inet)" in sql
assert "CAST(:estimate_id AS uuid)" in sql
assert "CAST(:payload AS jsonb)" in sql
def test_record_event_default_payload_is_empty_dict() -> None:
fake = _FakeSession()
with patch.object(ue, "SessionLocal", return_value=fake):
ue.record_event(event_type="login", username="user2")
_, params = fake.executed[0]
assert params is not None
assert params["payload"] == "{}"
assert params["ip"] is None
assert params["estimate_id"] is None
# ---------------------------------------------------------------------------
# (b) record_event never raises
# ---------------------------------------------------------------------------
def test_record_event_swallows_session_open_errors() -> None:
class _BrokenSession:
def __enter__(self) -> _BrokenSession:
raise RuntimeError("db unreachable")
def __exit__(self, *exc: object) -> bool:
return False
with patch.object(ue, "SessionLocal", return_value=_BrokenSession()):
# Should NOT raise.
ue.record_event(event_type="login", username="user1")
def test_record_event_swallows_execute_errors() -> None:
class _ExplodingSession(_FakeSession):
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
raise RuntimeError("syntax error at or near ':'")
with patch.object(ue, "SessionLocal", return_value=_ExplodingSession()):
ue.record_event(event_type="login", username="user1")
# ---------------------------------------------------------------------------
# (c)/(d) schedule_event
# ---------------------------------------------------------------------------
def test_schedule_event_sync_context_calls_record_event_inline() -> None:
"""No running loop (plain sync call, e.g. from a script/test) → inline call."""
with patch.object(ue, "record_event") as mock_record:
ue.schedule_event(event_type="login", username="user1", ip="9.9.9.9")
mock_record.assert_called_once_with(event_type="login", username="user1", ip="9.9.9.9")
async def test_schedule_event_async_context_is_non_blocking_and_calls_through() -> None:
"""Running loop present → backgrounded via asyncio.to_thread; does not block the
calling coroutine and eventually calls record_event."""
with patch.object(ue, "record_event") as mock_record:
ue.schedule_event(event_type="api_request", username="user1", path="/api/v1/x")
# schedule_event must return immediately without awaiting the background task.
# Give the scheduled task a chance to run before asserting.
await asyncio.sleep(0.05)
mock_record.assert_called_once_with(
event_type="api_request", username="user1", path="/api/v1/x"
)
async def test_schedule_event_background_failure_is_logged_not_raised() -> None:
"""A record_event failure inside the background task must not propagate/crash the
caller the done-callback swallows it (logs a warning)."""
with patch.object(ue, "record_event", side_effect=RuntimeError("boom")):
# Must not raise synchronously (the task runs in the background).
ue.schedule_event(event_type="login", username="user1")
await asyncio.sleep(0.05)
# Reaching here without an unhandled-exception bubbling up confirms the
# done-callback caught it.
# ---------------------------------------------------------------------------
# (e) should_log_login dedup
# ---------------------------------------------------------------------------
def test_should_log_login_true_once_then_false_same_day() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is False
def test_should_log_login_independent_per_ip() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("alice", "2.2.2.2", "chrome") is True
def test_should_log_login_independent_per_user() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("bob", "1.1.1.1", "chrome") is True
def test_should_log_login_independent_per_user_agent() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert ue.should_log_login("alice", "1.1.1.1", "firefox") is True
def test_should_log_login_none_ip_and_ua_are_valid_keys() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
assert ue.should_log_login("alice", None, None) is True
assert ue.should_log_login("alice", None, None) is False
def test_should_log_login_prunes_when_over_capacity() -> None:
ue._LOGIN_DEDUP_SEEN.clear()
for i in range(ue._LOGIN_DEDUP_MAX):
ue._LOGIN_DEDUP_SEEN.add(f"filler-{i}")
assert len(ue._LOGIN_DEDUP_SEEN) == ue._LOGIN_DEDUP_MAX
# Next call must prune (clear the whole set) before adding, not grow unbounded.
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
assert len(ue._LOGIN_DEDUP_SEEN) == 1
ue._LOGIN_DEDUP_SEEN.clear()
# ---------------------------------------------------------------------------
# (f) optional real-DB round trip (self-skips without a reachable test DB)
# ---------------------------------------------------------------------------
def _db_reachable() -> bool:
"""True if app.core.db's engine (the one record_event actually uses) points at a
real, non-placeholder Postgres and is reachable mirrors the live-DB skip pattern
in tests/test_house_dedup_merge.py."""
dsn = os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return False
try:
from sqlalchemy import text as _t
from app.core.db import engine
with engine.connect() as conn:
conn.execute(_t("SELECT 1"))
return True
except Exception:
return False
@pytest.mark.skipif(not _db_reachable(), reason="no reachable Postgres test DB")
def test_real_record_event_inserts_row() -> None:
"""End-to-end on the real DB record_event() is bound to (app.core.db.SessionLocal):
persists exactly the given fields and is queryable back."""
from sqlalchemy import text as _t
from app.core.db import SessionLocal
ue.record_event(
event_type="pytest_probe",
username="pytest_user_events_test",
ip="203.0.113.7",
user_agent="pytest-real-db",
path="/api/v1/trade-in/estimate",
method="POST",
payload={"probe": True},
)
with SessionLocal() as db:
try:
row = db.execute(
_t(
"SELECT event_type, username, ip_address, user_agent, payload "
"FROM user_events WHERE username = :u ORDER BY id DESC LIMIT 1"
),
{"u": "pytest_user_events_test"},
).fetchone()
assert row is not None
assert row.event_type == "pytest_probe"
assert str(row.ip_address) == "203.0.113.7"
assert row.payload == {"probe": True}
finally:
db.execute(
_t("DELETE FROM user_events WHERE username = :u"),
{"u": "pytest_user_events_test"},
)
db.commit()

View file

@ -27,7 +27,8 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
-- запуск упал между CREATE и финальным DROP под старым кодом (без source_id).
DROP TABLE IF EXISTS deals_ros_staging;
CREATE TABLE deals_ros_staging (
dedup_hash text PRIMARY KEY, source_id text, address text, rooms int, area_m2 numeric,
dedup_hash text PRIMARY KEY, source_id text, address text, region_code int, city text,
rooms int, area_m2 numeric,
floor int, year_built int, price_rub bigint, price_per_m2 int, deal_date date
);
"
@ -38,7 +39,9 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
SELECT
'ros:dkp:' || id::text AS dedup_hash,
id::text AS source_id,
'Екатеринбург, ' || trim(street) AS address,
trim(city) || ', ' || trim(street) AS address,
region_code AS region_code,
trim(city) AS city,
CASE
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
@ -51,7 +54,7 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
period_start_date AS deal_date
FROM rosreestr_deals
WHERE region_code = 66
AND city ILIKE '%катеринбург%'
AND city IS NOT NULL AND trim(city) <> ''
AND realestate_type_code = '002001003000'
AND area BETWEEN 18 AND 200
AND deal_price BETWEEN 1000000 AND 100000000
@ -71,10 +74,10 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
DELETE FROM deals WHERE address = 'Екатеринбург, реальная сделка';
INSERT INTO deals (
source, dedup_hash, source_id, address, rooms, area_m2, floor,
source, dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
year_built, price_rub, price_per_m2, deal_date
)
SELECT 'rosreestr', dedup_hash, source_id, address, rooms, area_m2, floor,
SELECT 'rosreestr', dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
year_built, price_rub, price_per_m2, deal_date
FROM deals_ros_staging
ON CONFLICT (dedup_hash) DO NOTHING;

View file

@ -0,0 +1,252 @@
"use client";
/**
* Активность (Feature 3) поведенческая аналитика по user_events: сколько
* событий, кто активен, что чаще ищут/дёргают. Названо «Активность», не
* «Аналитика» этот label уже занят market-analytics overlay (АНАЛИТИКА ДОМА,
* v2/AnalyticsView.tsx) внутри самой оценки, здесь другой домен (юзер-активность).
*
* Admin-only тот же паттерн gating, что и /admin/audit (см. комментарий там).
*/
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { Topbar } from "@/components/trade-in/Topbar";
import { HTTPError } from "@/lib/api";
import { useUserAnalytics } from "@/lib/admin-audit-api";
const DAYS_OPTIONS = [7, 30, 90] as const;
type DaysOption = (typeof DAYS_OPTIONS)[number];
function formatDay(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
} catch {
return iso;
}
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function KpiCard({ label, value }: { label: string; value: number }) {
return (
<div className="admin-kpi-card">
<div className="admin-kpi-label">{label}</div>
<div className="admin-kpi-value">{value.toLocaleString("ru-RU")}</div>
</div>
);
}
interface DailyChartPoint {
day: string;
events: number;
users: number;
label: string;
}
export default function AdminAnalyticsPage() {
const [days, setDays] = useState<DaysOption>(30);
const analyticsQ = useUserAnalytics(days);
const chartData = useMemo<DailyChartPoint[]>(
() => (analyticsQ.data?.daily ?? []).map((p) => ({ ...p, label: formatDay(p.day) })),
[analyticsQ.data],
);
if (analyticsQ.error instanceof HTTPError && analyticsQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
return (
<>
<Topbar active="analytics" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Активность</h1>
<p className="scraper-subtitle">
Поведенческая аналитика по всем аккаунтам: события, активные
пользователи, частые поиски и API-пути.
</p>
<div className="admin-days-filter">
{DAYS_OPTIONS.map((d) => (
<button
key={d}
type="button"
className={days === d ? "is-active" : undefined}
onClick={() => setDays(d)}
>
{d} дн.
</button>
))}
</div>
{analyticsQ.isPending && <p className="scraper-hint">Загрузка</p>}
{analyticsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {analyticsQ.error.message}
</p>
)}
{analyticsQ.data && (
<>
<div className="admin-kpi-grid">
<KpiCard label="Всего событий" value={analyticsQ.data.summary.total_events} />
<KpiCard
label="Уникальных пользователей"
value={analyticsQ.data.summary.distinct_users}
/>
<KpiCard label="События за 24ч" value={analyticsQ.data.summary.events_last_24h} />
<KpiCard
label="Активны за 24ч"
value={analyticsQ.data.summary.active_users_last_24h}
/>
</div>
<section className="scraper-section">
<h2>Динамика по дням</h2>
<p className="scraper-hint">События и уникальные пользователи в день.</p>
{chartData.length < 2 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Недостаточно данных для графика нужно минимум 2 дня наблюдений.
</p>
) : (
<div aria-hidden="true" style={{ width: "100%", height: 260 }}>
<ResponsiveContainer>
<LineChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid stroke="var(--border-card, #e6e8ec)" strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip labelFormatter={(label: string) => label} />
<Line
type="monotone"
dataKey="events"
stroke="var(--accent, #1d4ed8)"
strokeWidth={2}
dot={{ r: 2 }}
name="События"
/>
<Line
type="monotone"
dataKey="users"
stroke="var(--success, #0a7a3a)"
strokeWidth={2}
dot={{ r: 2 }}
name="Пользователи"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
<div className="admin-two-col">
<section className="scraper-section">
<h2>Топ поисковых адресов</h2>
{analyticsQ.data.top_searches.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Адрес</th>
<th scope="col">Поисков</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.top_searches.map((row, i) => (
<tr key={i}>
<td>{row.address ?? "—"}</td>
<td>{row.n}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="scraper-section">
<h2>Топ API-путей</h2>
{analyticsQ.data.top_paths.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Путь</th>
<th scope="col">Запросов</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.top_paths.map((row, i) => (
<tr key={i}>
<td>{row.path ?? "—"}</td>
<td>{row.n}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<section className="scraper-section">
<h2>Активность по аккаунтам</h2>
{analyticsQ.data.by_account.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Аккаунт</th>
<th scope="col">События</th>
<th scope="col">Поиски</th>
<th scope="col">Последняя активность</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.by_account.map((row) => (
<tr key={row.username}>
<td>{row.username}</td>
<td>{row.events}</td>
<td>{row.searches}</td>
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
</main>
</>
);
}

View file

@ -0,0 +1,248 @@
"use client";
/**
* Аудит доступа (Feature 2) с каких IP/устройств заходят пилотные аккаунты и
* что они ищут. Admin-only: nav entry в Topbar скрыт для не-admin ролей (see
* NAV_ITEMS scopePath), backend 403-ит non-admin регардлесс обрабатываем
* это здесь, а не крашимся (RouteGuard сам по себе НЕ блокирует прямой заход
* на /admin/audit для pilot, см. комментарий в Topbar.tsx про /scrapers
* тот же паттерн).
*/
import { Fragment, useState, type ReactNode } from "react";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { Topbar } from "@/components/trade-in/Topbar";
import { HTTPError } from "@/lib/api";
import {
useAccountDrilldown,
useAuditAccounts,
type AccountDrilldown,
} from "@/lib/admin-audit-api";
function formatDateTime(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function DrilldownSection<T>({
title,
rows,
render,
}: {
title: string;
rows: T[];
render: (row: T, i: number) => ReactNode;
}) {
return (
<div>
<h4>
{title} ({rows.length})
</h4>
{rows.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Нет данных.
</p>
) : (
<table className="runs-table">
<tbody>{rows.map(render)}</tbody>
</table>
)}
</div>
);
}
function AccountDrilldownPanel({ username }: { username: string }) {
const drilldownQ = useAccountDrilldown(username);
if (drilldownQ.isError) {
return (
<p className="scraper-result scraper-result--error" style={{ margin: "12px 6px" }}>
Не удалось загрузить детали аккаунта: {drilldownQ.error.message}
</p>
);
}
const d: AccountDrilldown | undefined = drilldownQ.data;
if (!d) {
return (
<p className="scraper-hint" style={{ margin: "12px 6px" }}>
Загрузка деталей
</p>
);
}
const hasAnyData =
d.ips.length > 0 ||
d.devices.length > 0 ||
d.searches.length > 0 ||
d.recent_activity.length > 0;
if (!hasAnyData) {
return (
<p className="scraper-hint" style={{ margin: "12px 6px" }}>
Пока нет данных по этому аккаунту.
</p>
);
}
return (
<div className="admin-drilldown">
<DrilldownSection
title="IP-адреса"
rows={d.ips}
render={(row, i) => (
<tr key={i}>
<td>{row.ip_address ?? "—"}</td>
<td className="run-muted">{row.event_count} событ.</td>
<td className="run-muted">
{formatDateTime(row.first_seen)} {formatDateTime(row.last_seen)}
</td>
</tr>
)}
/>
<DrilldownSection
title="Устройства"
rows={d.devices}
render={(row, i) => (
<tr key={i}>
<td style={{ maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.user_agent ?? "—"}
</td>
<td className="run-muted">{row.event_count} событ.</td>
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
</tr>
)}
/>
<DrilldownSection
title="Поиски"
rows={d.searches}
render={(row, i) => (
<tr key={i}>
<td>{row.address ?? "—"}</td>
<td className="run-muted">
{row.rooms ?? "?"}к · {row.area_m2 ?? "?"} м²
</td>
<td className="run-muted">
{formatDateTime(row.created_at)} · {row.ip_address ?? "—"}
</td>
</tr>
)}
/>
<DrilldownSection
title="Недавняя активность"
rows={d.recent_activity}
render={(row, i) => (
<tr key={i}>
<td>{row.event_type}</td>
<td className="run-muted">
{row.method ?? ""} {row.path ?? "—"}
</td>
<td className="run-muted">{formatDateTime(row.created_at)}</td>
</tr>
)}
/>
</div>
);
}
export default function AdminAuditPage() {
const accountsQ = useAuditAccounts();
const [expandedUser, setExpandedUser] = useState<string | null>(null);
// Backend 403-ит non-admin регардлесс (nav уже прячет ссылку) — прямой заход
// на URL не должен крашить страницу, показываем тот же экран, что RouteGuard
// рендерит для path-deny.
if (accountsQ.error instanceof HTTPError && accountsQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
return (
<>
<Topbar active="audit" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Аудит доступа</h1>
<p className="scraper-subtitle">
С каких IP-адресов и устройств заходят аккаунты и что они ищут. Клик по
аккаунту раскрывает детали.
</p>
<section className="scraper-section">
{accountsQ.isPending && <p className="scraper-hint">Загрузка</p>}
{accountsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {accountsQ.error.message}
</p>
)}
{accountsQ.data && accountsQ.data.length === 0 && (
<p className="scraper-hint">
Пока нет данных событий аудита ещё не собрано.
</p>
)}
{accountsQ.data && accountsQ.data.length > 0 && (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Аккаунт</th>
<th scope="col">Последний визит</th>
<th scope="col">Первый визит</th>
<th scope="col">IP</th>
<th scope="col">Устройства</th>
<th scope="col">Входы</th>
<th scope="col">Запросы</th>
<th scope="col">Поиски</th>
</tr>
</thead>
<tbody>
{accountsQ.data.map((a) => {
const isOpen = expandedUser === a.username;
return (
<Fragment key={a.username}>
<tr>
<td>
<button
type="button"
className="admin-row-toggle"
aria-expanded={isOpen}
onClick={() => setExpandedUser(isOpen ? null : a.username)}
>
{isOpen ? "▾" : "▸"} {a.username}
</button>
</td>
<td className="run-muted">{formatDateTime(a.last_seen_at)}</td>
<td className="run-muted">{formatDateTime(a.first_seen_at)}</td>
<td>{a.distinct_ips}</td>
<td>{a.distinct_devices}</td>
<td>{a.login_count}</td>
<td>{a.request_count}</td>
<td>{a.search_count}</td>
</tr>
{isOpen && (
<tr>
<td colSpan={8} style={{ background: "var(--bg-card-alt, #fafbfc)" }}>
<AccountDrilldownPanel username={a.username} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</section>
</main>
</>
);
}

View file

@ -448,10 +448,17 @@ export default function TradeInV2Page() {
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
// inside the scaled artboard — smaller on phones, but no longer overflowing.
const [isMobile, setIsMobile] = useState(false);
// Below this width the fixed 1536px artboard scales down to an unreadable
// ~25% and there is no responsive layout yet. Rather than ship that broken
// scaled-down HUD, render an honest "open on desktop" notice instead (see the
// early return below). Starts false so SSR + first client render match the
// desktop tree; the effect flips it post-hydration → no hydration mismatch.
const [isSmallViewport, setIsSmallViewport] = useState(false);
useEffect(() => {
const compute = () => {
setArtboardScale(Math.min(1, window.innerWidth / 1536));
setIsMobile(window.innerWidth < 768);
setIsSmallViewport(window.innerWidth < 1024);
};
compute();
window.addEventListener("resize", compute);
@ -766,6 +773,48 @@ export default function TradeInV2Page() {
statusMessage = "Оценка готова";
}
// Small-viewport guard: the HUD is a fixed 1536px artboard with no responsive
// layout — under ~1024px it scales to an unreadable ~25%. Show an honest
// desktop-only notice instead of the broken scaled-down copy. Placed after all
// hooks so the early return never changes hook order. Desktop (≥1024) is
// untouched.
if (isSmallViewport) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "40px 24px",
boxSizing: "border-box",
gap: 14,
fontFamily: tokens.font.sans,
color: tokens.ink,
background: tokens.gradientBg,
}}
>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: 0.3 }}>
Мера оптимизирована для десктопа
</h1>
<p
style={{
fontSize: 14,
lineHeight: 1.6,
margin: 0,
maxWidth: 360,
color: tokens.body,
}}
>
Откройте оценку на компьютере или расширьте окно браузера до 1024&nbsp;px
и шире на узком экране интерфейс отчёта нечитаем.
</p>
</div>
);
}
return (
<div
style={{
@ -974,6 +1023,7 @@ export default function TradeInV2Page() {
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
estimate={estimate}
onSelectEstimate={(id) => {
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
// above (line ~649); this file uses replace() consistently for

View file

@ -12,7 +12,7 @@
import { logout } from "@/lib/logout";
interface NoAccessScreenProps {
variant: "user" | "path" | "session" | "trial";
variant: "user" | "path" | "session" | "trial" | "error";
path?: string;
}
@ -22,16 +22,20 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
? "Сессия истекла"
: variant === "trial"
? "Пробный доступ закончился"
: "Доступа нет";
: variant === "error"
? "Не удалось проверить доступ"
: "Доступа нет";
const subtitle =
variant === "session"
? "Сессия истекла или вы не авторизованы. Обновите страницу, чтобы войти снова."
: variant === "user"
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
: variant === "path"
? "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."
: null;
? "Сессия истекла или вы не авторизованы. Нажмите «Войти снова», чтобы пройти авторизацию заново."
: variant === "error"
? "Возникла техническая проблема при проверке доступа. Проверьте подключение к интернету и обновите страницу. Если ошибка повторяется — обратитесь к администратору GenDesign."
: variant === "user"
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
: variant === "path"
? "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."
: null;
return (
<main
@ -118,26 +122,51 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
{path}
</p>
) : null}
{/* Always-available logout иначе locked/no-access юзер заперт:
UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
<button
type="button"
onClick={() => logout()}
style={{
marginTop: 20,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
fontFamily: "inherit",
}}
>
Выйти
</button>
{/* Always-available primary action иначе locked/no-access юзер заперт:
UserMenu (где обычно «Выйти») на этом экране не рендерится.
session/error сбой временный (истёкшая сессия / сетевой сбой),
поэтому CTA повторить попытку через reload, а не logout.
basic_auth Caddy: reload на top-level navigation триггерит
повторный auth-challenge браузера при отклонённых creds. */}
{variant === "session" || variant === "error" ? (
<button
type="button"
onClick={() => window.location.reload()}
style={{
marginTop: 20,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
fontFamily: "inherit",
}}
>
{variant === "session" ? "Войти снова" : "Обновить страницу"}
</button>
) : (
<button
type="button"
onClick={() => logout()}
style={{
marginTop: 20,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
fontFamily: "inherit",
}}
>
Выйти
</button>
)}
</div>
</main>
);

View file

@ -58,7 +58,10 @@ export function RouteGuard({ children }: RouteGuardProps) {
}
if (error) {
return <NoAccessScreen variant="user" />;
// Не 401/403 — 500 / сетевой сбой / таймаут. НЕ переиспользуем variant="user"
// (текст «аккаунт не привязан к роли» вводит в заблуждение при техническом
// сбое) — отдельный экран с честной формулировкой + reload CTA.
return <NoAccessScreen variant="error" />;
}
if (!data) return null;

View file

@ -19,8 +19,10 @@
* покажется заново.
*/
import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import { API_BASE_URL } from "@/lib/api";
import { logout } from "@/lib/logout";
import { useMe } from "@/lib/useMe";
@ -46,6 +48,69 @@ function LogOutIcon() {
);
}
/** Inline Shield icon (lucide-react `Shield` SVG path, stroke 1.5) для
admin-ссылки «Аудит доступа». Та же inline-SVG конвенция, что LogOutIcon
(tradein-mvp не тянет lucide-react в deps). */
function ShieldIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>
);
}
/** Inline BarChart icon (lucide-react `BarChart3` SVG path, stroke 1.5)
для admin-ссылки «Активность». */
function BarChartIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M18 17V9" />
<path d="M13 17V5" />
<path d="M8 17v-3" />
</svg>
);
}
/** Общий inline-стиль для admin-nav-пунктов дропдауна (Аудит/Активность)
тот же паттерн, что «Выйти», но нейтральный цвет вместо var(--danger). */
const adminMenuItemStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
background: "none",
border: "none",
padding: "4px 0",
cursor: "pointer",
color: "var(--fg-primary)",
fontWeight: 500,
fontSize: 14,
textAlign: "left",
fontFamily: "inherit",
textDecoration: "none",
};
export function UserMenu() {
const { data, isLoading, error } = useMe();
const [open, setOpen] = useState(false);
@ -159,6 +224,35 @@ export function UserMenu() {
>
{roleLabel}
</div>
{data.role === "admin" && (
<>
<hr
style={{
margin: "12px 0",
border: "none",
borderTop: "1px solid var(--border-soft)",
}}
/>
<a
role="menuitem"
href={`${API_BASE_URL}/admin/audit`}
onClick={() => setOpen(false)}
style={adminMenuItemStyle}
>
<ShieldIcon />
Аудит доступа
</a>
<a
role="menuitem"
href={`${API_BASE_URL}/admin/analytics`}
onClick={() => setOpen(false)}
style={{ ...adminMenuItemStyle, marginTop: 4 }}
>
<BarChartIcon />
Активность
</a>
</>
)}
<hr
style={{
margin: "12px 0",

View file

@ -9,6 +9,58 @@ import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand";
import { useMe } from "@/lib/useMe";
/** Telegram-канал обратной связи для пилота. Build-time env пусто до тех
пор, пока devops не настроит реальную ссылку (см. FeedbackButton ниже). */
const FEEDBACK_TG_URL = process.env.NEXT_PUBLIC_FEEDBACK_TG_URL ?? "";
/** Inline Send icon (lucide-react `Send` SVG path, stroke 1.5) та же
конвенция, что и LogOutIcon в UserMenu.tsx: tradein-mvp не тянет
lucide-react в deps, поэтому иконка inline SVG. */
function TelegramIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
);
}
/**
* Кнопка обратной связи в Telegram рендерится для ВСЕХ авторизованных
* пользователей (не зависит от RBAC allowed_paths, в отличие от NAV_ITEMS).
*
* Env-gated: пока `NEXT_PUBLIC_FEEDBACK_TG_URL` не задан на билде, кнопка не
* рендерится вообще безопасно шипить до появления реальной Telegram-ссылки.
* safeUrl() дополнительно блокирует не-http(s) схемы (#766-class XSS).
*/
function FeedbackButton() {
const href = safeUrl(FEEDBACK_TG_URL);
if (!href) return null;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="top-cta"
aria-label="Обратная связь в Telegram"
>
<TelegramIcon />
Обратная связь
</a>
);
}
/** «Мера» mark буква М над размерной линией («мера» = измерение).
Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */
function MeraMark() {
@ -30,7 +82,9 @@ export type ActiveTab =
| "scrapers"
| "avito"
| "cian"
| "yandex";
| "yandex"
| "audit"
| "analytics";
interface TopbarProps {
active: ActiveTab;
@ -93,6 +147,21 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс",
},
// Аудит доступа (Feature 2) + Активность (Feature 3) — admin-only read API
// над user_events (см. app/api/v1/audit.py). Тот же admin-scopePath паттерн,
// что и скрапперы выше: pilot.deny покрывает /trade-in/api/v1/admin/**.
{
key: "audit",
href: "/admin/audit",
scopePath: "/trade-in/api/v1/admin/audit",
label: "Аудит",
},
{
key: "analytics",
href: "/admin/analytics",
scopePath: "/trade-in/api/v1/admin/analytics",
label: "Активность",
},
];
export function Topbar({ active }: TopbarProps) {
@ -156,6 +225,10 @@ export function Topbar({ active }: TopbarProps) {
{/* UserMenu личный кабинет справа от nav. В dev (401) или до /me
UserMenu сам рендерит null. */}
<UserMenu />
{/* FeedbackButton вне items.map/RBAC: видна всем авторизованным
пользователям независимо от allowed_paths. Сама себя прячет,
пока NEXT_PUBLIC_FEEDBACK_TG_URL не задан. */}
<FeedbackButton />
</nav>
</div>
</header>

View file

@ -2732,3 +2732,91 @@ html, body { overflow-x: clip; }
.ss-hist { height: 76px; }
.ss-map, .ss-map-empty { height: 360px; }
}
/* ── Admin dashboards: audit (Feature 2) + activity (Feature 3), #2521 ── */
.admin-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.admin-kpi-card {
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 10px;
padding: 14px 16px;
}
.admin-kpi-label {
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-secondary, #5b6066);
}
.admin-kpi-value {
font-size: 26px;
font-weight: 600;
color: var(--fg-primary, #111111);
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.admin-days-filter {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.admin-days-filter button {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--border-card, #e6e8ec);
font-size: 13px;
cursor: pointer;
background: var(--bg-card, #fff);
color: var(--fg-secondary, #5b6066);
}
.admin-days-filter button.is-active {
background: var(--accent, #1d4ed8);
border-color: var(--accent, #1d4ed8);
color: #fff;
}
.admin-row-toggle {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent, #1d4ed8);
cursor: pointer;
text-align: left;
}
.admin-row-toggle:hover {
text-decoration: underline;
}
.admin-drilldown {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 14px 6px;
}
.admin-drilldown h4 {
font-size: 12px;
font-weight: 600;
color: var(--fg-primary, #111111);
margin: 0 0 8px;
}
.admin-two-col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}

View file

@ -8,7 +8,7 @@
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import { phYearX as histYearX } from "./mappers";
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
@ -115,9 +115,18 @@ function sellTimeTierN(tier: SellTimeTier): number {
return Number.isFinite(n) ? n : 0;
}
/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
/**
* A tier is trustworthy only with a real point estimate, SELLTIME_MIN_N lots,
* AND the backend not having flagged it as insufficient (V3) a bucket the
* backend itself calls too thin is dropped like any other untrustworthy tile,
* never shown as a confident value.
*/
function sellTimeTierValid(tier: SellTimeTier): boolean {
return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
return (
!tier.insufficientData &&
tier.days !== "—" &&
sellTimeTierN(tier) >= SELLTIME_MIN_N
);
}
export default function AnalyticsView({
@ -318,357 +327,402 @@ export default function AnalyticsView({
)}
</div>
{/* история цен */}
<div style={{ position: "relative", ...cardStyle }}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote}
</div>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
{avitoPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
) : null}
{yandexPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.gold }}
/>
Яндекс
</span>
) : null}
</div>
</div>
<svg
viewBox="0 0 900 220"
style={{ width: "100%", height: 200, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
>
<title>История цен в этом доме медиана /м² по годам</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={40} y1={20} x2={900} y2={20} />
<line x1={40} y1={65} x2={900} y2={65} />
<line x1={40} y1={110} x2={900} y2={110} />
<line x1={40} y1={155} x2={900} y2={155} />
<line x1={40} y1={195} x2={900} y2={195} />
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.yTicks.map((t) => (
<text key={t.label} x={0} y={t.y + 4}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{data.priceHistory.years.map((yr, i) => (
<text
key={yr}
x={histYearX(i, data.priceHistory.years.length)}
y={214}
>
{yr}
</text>
))}
</g>
<polyline
points={data.priceHistory.avito}
fill="none"
stroke={tokens.accent}
strokeWidth={2.5}
/>
<polyline
points={data.priceHistory.yandex}
fill="none"
stroke={tokens.gold}
strokeWidth={2.5}
/>
{/* white dot fill on an accent ring — documented one-off, no token (design l.568) */}
<g
fill="#fff"
stroke={tokens.accent}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
{avitoPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill={tokens.gold}
stroke={tokens.gold}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
{yandexDots.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill="transparent"
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoverHist(true)}
onMouseLeave={() => setHoverHist(false)}
onClick={() => onNavigate?.(2)}
>
{hoverPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={13} />
))}
</g>
</svg>
{hoverHist ? (
// Dark navy tooltip surface + white text — documented one-off literals
// matching the design (l.572); no token equivalent.
{/* история цен + цена × срок продажи side by side so both fit the
fixed-height v2 artboard panel without scrolling (reclaims ~194px
of vertical space vs. stacking). Both SVGs share height: 260 (viewBox
heights differ 220 vs 300 but preserveAspectRatio="none" stretches
them to match) so the two cards render at equal height. */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
alignItems: "stretch",
}}
>
{/* история цен */}
<div style={{ position: "relative", ...cardStyle }}>
<div
style={{
position: "absolute",
left: 64,
top: 54,
background: "rgba(23,38,58,.96)",
color: "#fff",
borderRadius: 7,
padding: "9px 13px",
fontSize: 11,
lineHeight: 1.5,
zIndex: 6,
pointerEvents: "none",
boxShadow: "0 10px 26px rgba(20,40,70,.28)",
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div style={{ fontWeight: 600 }}>
Архивные объявления этого дома
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{historyNote}
</div>
</div>
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
Нажмите, чтобы открыть список
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
{avitoPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
) : null}
{yandexPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.gold }}
/>
Яндекс
</span>
) : null}
</div>
</div>
) : null}
</div>
{/* цена × срок продажи (детально) */}
<div style={cardStyle}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Цена × срок продажи
<svg
viewBox="0 0 900 220"
style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
>
<title>История цен в этом доме медиана /м² по годам</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
{PH_GRID_Y.map((y) => (
<line key={y} x1={40} y1={y} x2={900} y2={y} />
))}
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.yTicks.map((t) => (
<text key={t.label} x={0} y={t.y + 4}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{data.priceHistory.years.map((yr, i) => (
<text
key={yr}
x={histYearX(i, data.priceHistory.years.length)}
y={214}
>
{yr}
</text>
))}
</g>
<polyline
points={data.priceHistory.avito}
fill="none"
stroke={tokens.accent}
strokeWidth={2.5}
/>
<polyline
points={data.priceHistory.yandex}
fill="none"
stroke={tokens.gold}
strokeWidth={2.5}
/>
{/* white dot fill on an accent ring — documented one-off, no token (design l.568) */}
<g
fill="#fff"
stroke={tokens.accent}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
{avitoPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill={tokens.gold}
stroke={tokens.gold}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
{yandexDots.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
{/* WCAG 2.1.1 (#4): the chart's "open archived listings" affordance
was a mouse-only <g onClick>. Expose it as a real focusable
button (role + tabIndex + Enter/Space) so keyboard/SR users can
open the «Аналогичные объявления» overlay too the transparent
hover circles stay as the pointer target. Only interactive when
there are points to click (no empty tab stop on a data-less
chart). onFocus/onBlur mirror hover so the hint tooltip shows on
keyboard focus as well. */}
<g
fill="transparent"
role={hoverPts.length > 0 ? "button" : undefined}
tabIndex={hoverPts.length > 0 ? 0 : undefined}
aria-label={
hoverPts.length > 0
? "Открыть архивные объявления этого дома"
: undefined
}
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoverHist(true)}
onMouseLeave={() => setHoverHist(false)}
onFocus={() => setHoverHist(true)}
onBlur={() => setHoverHist(false)}
onClick={() => onNavigate?.(2)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNavigate?.(2);
}
}}
>
{hoverPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={13} />
))}
</g>
</svg>
{hoverHist ? (
// Dark navy tooltip surface + white text — documented one-off literals
// matching the design (l.572); no token equivalent.
<div
style={{
position: "absolute",
left: 64,
top: 54,
background: "rgba(23,38,58,.96)",
color: "#fff",
borderRadius: 7,
padding: "9px 13px",
fontSize: 11,
lineHeight: 1.5,
zIndex: 6,
pointerEvents: "none",
boxShadow: "0 10px 26px rgba(20,40,70,.28)",
}}
>
<div style={{ fontWeight: 600 }}>
Архивные объявления этого дома
</div>
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
Нажмите, чтобы открыть список
</div>
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
аналоги
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.ink,
}}
/>
объект
</span>
</div>
) : null}
</div>
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }}
role="img"
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
>
<title>Цена × срок продажи</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={60} y1={20} x2={880} y2={20} />
<line x1={60} y1={77} x2={880} y2={77} />
<line x1={60} y1={134} x2={880} y2={134} />
<line x1={60} y1={191} x2={880} y2={191} />
<line x1={60} y1={250} x2={880} y2={250} />
<line x1={265} y1={20} x2={265} y2={250} />
<line x1={470} y1={20} x2={470} y2={250} />
<line x1={675} y1={20} x2={675} y2={250} />
</g>
<line
x1={60}
y1={20}
x2={60}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={60}
y1={250}
x2={880}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={100}
y1={226}
x2={820}
y2={55}
stroke={tokens.accent}
strokeWidth={1.5}
strokeDasharray="5 5"
opacity={0.45}
/>
<g fill={tokens.scatterDeal}>
{data.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={tokens.accent}>
{data.scatterDetail.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={data.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={11}
fill="none"
stroke={tokens.ink}
strokeWidth={1.2}
opacity={0.4}
/>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="end"
{/* цена × срок продажи (детально) */}
<div style={cardStyle}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
{data.scatterDetail.yTicks.map((t) => (
<text key={t.label} x={52} y={t.y}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Цена × срок на рынке
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{/* Honesty (#2): active analogs carry «дней на рынке так далеко»
(today listing_date, нижняя граница), not realized
time-to-sell the axis reads «срок на рынке», not «срок
продажи», so an ещё-продающийся лот isn't shown as a
measured sale time. */}
Цена в зависимости от срока на рынке · {data.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
аналоги
</span>
{data.scatterDetail.deals.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
) : null}
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.ink,
}}
/>
объект
</span>
</div>
</div>
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="Точечный график зависимости цены от срока лота на рынке: аналоги, сделки и ваш объект"
>
{data.scatterDetail.xTicks.map((t) => (
<text key={t.label} x={t.x} y={268}>
{t.label}
</text>
))}
</g>
<text
x={470}
y={288}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
>
СРОК ПРОДАЖИ, ДН
</text>
<text
x={18}
y={135}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
transform="rotate(-90 18 135)"
>
ЦЕНА ПРОДАЖИ,
</text>
</svg>
<title>Цена × срок на рынке</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={60} y1={20} x2={880} y2={20} />
<line x1={60} y1={77} x2={880} y2={77} />
<line x1={60} y1={134} x2={880} y2={134} />
<line x1={60} y1={191} x2={880} y2={191} />
<line x1={60} y1={250} x2={880} y2={250} />
<line x1={265} y1={20} x2={265} y2={250} />
<line x1={470} y1={20} x2={470} y2={250} />
<line x1={675} y1={20} x2={675} y2={250} />
</g>
<line
x1={60}
y1={20}
x2={60}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={60}
y1={250}
x2={880}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<g fill={tokens.scatterDeal}>
{data.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={tokens.accent}>
{data.scatterDetail.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g opacity={data.scatterDetail.subjectApprox ? 0.55 : 1}>
{/* V4: subjectApprox marks a fallback x (est_days_on_market unknown)
dashed ring + title flag it as an estimate, not a measurement. */}
{data.scatterDetail.subjectApprox ? (
<title>
Срок продажи оценка (~90 дн.), точных данных нет
</title>
) : null}
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={data.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={11}
fill="none"
stroke={tokens.ink}
strokeWidth={1.2}
strokeDasharray={
data.scatterDetail.subjectApprox ? "3 3" : undefined
}
opacity={0.4}
/>
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="end"
>
{data.scatterDetail.yTicks.map((t) => (
<text key={t.label} x={52} y={t.y}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{data.scatterDetail.xTicks.map((t) => (
<text key={t.label} x={t.x} y={268}>
{t.label}
</text>
))}
</g>
<text
x={470}
y={288}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
>
СРОК НА РЫНКЕ, ДН
</text>
<text
x={18}
y={135}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
transform="rotate(-90 18 135)"
>
ЦЕНА ПРОДАЖИ,
</text>
</svg>
</div>
</div>
</div>
);

View file

@ -406,23 +406,31 @@ export default function HeroBar({
{data.object.city}
</span>
</div>
<div
style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
{/* streetView Fix #4 (audit): always "" (TODO BE-3, mappers.ts
mapObject), so this slot + its divider are hidden together
rather than showing an empty caption row. Leaves the single
divider below to separate the address block from the coef row. */}
{data.object.streetView && (
<>
<div
style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
</>
)}
<div
style={{
height: 1,
@ -520,114 +528,52 @@ export default function HeroBar({
</div>
</div>
{/* compass */}
<div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
aria-hidden="true"
>
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
{/* compass Fix #4 (audit): compass bearing is always "" (TODO BE-3,
mappers.ts mapObject), so the icon+label are hidden rather than
showing a compass that never actually points anywhere. Mirrors the
locationCoef "нет данных" graceful-fallback pattern already used
in this file. */}
{data.object.compass && (
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
{data.object.compass}
</span>
</div>
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
aria-hidden="true"
>
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}}
>
{data.object.compass}
</span>
</div>
)}
{/* distance scale */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: 16,
width: 170,
fontFamily: tokens.font.mono,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
height: 8,
borderBottom: "1px solid rgba(46,139,255,.55)",
}}
>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "7.5px",
color: tokens.muted,
marginTop: 3,
}}
>
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100м</span>
</div>
</div>
{/* Fix #3 (audit) the "0/25/50/75/100м" distance ruler here was a
hardcoded tick scale over a generic stock photo with no real
measurement behind it (not tied to any actual distance/scale
value). Removed rather than fabricating a scale. */}
{/* corner brackets */}
<div

View file

@ -358,6 +358,19 @@ export function LocationDrawer({
{data.resultLabel}
</span>
</div>
{/* Fix #7c (audit): the estimator never reads this coefficient it's
illustrative/reference only. Without this line the -vs- layout
above reads as if it changes the quoted price. */}
<div
style={{
marginTop: 8,
fontSize: 10.5,
lineHeight: 1.5,
color: tokens.muted3,
}}
>
Справочно: поправка на локацию не влияет на итоговую оценку.
</div>
{data.factors.length > 0 ? (
<ul
style={{

View file

@ -273,6 +273,11 @@ export function ObjectSummary({
</div>
{/* КАЧЕСТВО ДАННЫХ */}
{/* Fix #2 (audit) the donut ring here used to be a hardcoded
strokeDasharray/strokeDashoffset (~43%, never changed): no composite
data-quality SCORE exists anywhere in the mapper to back a ring
fraction, so it always lied. Removed rather than inventing a metric;
the three real rows below (Источников/Достоверность/Разброс) stay. */}
<div
style={{
marginTop: 14,
@ -280,34 +285,9 @@ export function ObjectSummary({
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 6,
padding: 12,
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.track}
strokeWidth="3"
/>
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="126"
strokeDashoffset="72"
transform="rotate(-90 25 25)"
/>
<circle cx="25" cy="25" r="11" stroke="#cdddec" strokeWidth="1" />
<circle cx="25" cy="25" r="1.6" fill={tokens.accent} />
</svg>
<div style={{ flex: 1 }}>
<div>
<div
style={{
fontSize: 9.5,

View file

@ -1078,6 +1078,23 @@ export default function ParamsPanel({
/>
</div>
{/* Fix #7 (audit) the SVG above is decorative (grid + fake streets +
radius rings), not a real map, and the rings themselves aren't drawn
to scale (outerRingR is a clamped best-effort, not a true geometric
projection of РАДИУС). Same honesty-caption tone as SourcesMap's
footer disclosure. */}
<div
style={{
fontSize: 9,
color: tokens.hint,
lineHeight: 1.4,
marginTop: 6,
flex: "0 0 auto",
}}
>
Схематично · не географическая карта · радиус не в масштабе
</div>
{/* radius row */}
<div
style={{
@ -1634,41 +1651,47 @@ export default function ParamsPanel({
</div>
)}
{/* cache status */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 11,
flex: "0 0 auto",
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
<span
{/* cache status Fix #6 (audit): was static, so it showed "fresh" before
any estimate existed. Gated on hasEstimate (same signal that already
swaps the CTA to «СКАЧАТЬ PDF-ОТЧЁТ» above) the 24 Ч TTL itself is a
real cache constant (ParamsPanel/mappers.ts cacheStatus), only the
"always visible" part was dishonest. */}
{hasEstimate && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.success,
justifyContent: "space-between",
marginTop: 11,
flex: "0 0 auto",
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
<span
className="pp-dot"
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.success,
boxShadow: `0 0 6px ${tokens.success}`,
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.success,
}}
/>
ДЕЙСТВИТЕЛЕН 24 Ч
</span>
</div>
>
<span
className="pp-dot"
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.success,
boxShadow: `0 0 6px ${tokens.success}`,
}}
/>
ДЕЙСТВИТЕЛЕН 24 Ч
</span>
</div>
)}
</div>
);
}

View file

@ -789,6 +789,14 @@ export default function ResultPanel({
Подробнее <span></span>
</button>
</div>
{/* Fix #1 (audit) the tile counts below are summed from the top-10
persisted sample, which can be less than builtOn's full n_analogs
total (footer, below) for large result sets. */}
{data.meta.sourcesNote && (
<div style={{ fontSize: 9.5, color: hint, marginBottom: 8 }}>
{data.meta.sourcesNote}
</div>
)}
<div
style={{
display: "grid",

View file

@ -16,6 +16,7 @@ import AnalyticsView from "./AnalyticsView";
import { CacheView } from "./CacheView";
import type { CacheData, HistoryData, SourcesData } from "./mappers";
import type { Analytics } from "./types";
import type { AggregatedEstimate } from "@/types/trade-in";
interface SectionOverlayProps {
active: number;
@ -28,6 +29,11 @@ interface SectionOverlayProps {
sources?: SourcesData;
analytics?: Analytics;
cache?: CacheData;
// Raw current estimate — threaded straight to SourcesView's SourcesMap
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
// strings with no geometry). Optional/null: no estimate yet -> map degrades
// to its own honest-empty (renders nothing, tables still show).
estimate?: AggregatedEstimate | null;
// #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for
// that historical estimate. Optional (threaded straight to CacheView's own
// optional onSelectRow — unwired usage leaves rows inert).
@ -47,6 +53,7 @@ export default function SectionOverlay({
sources,
analytics,
cache,
estimate = null,
onSelectEstimate,
}: SectionOverlayProps) {
// Non-modal dialog plumbing. `dialogRef` is the dialog root, `lastFocused`
@ -317,7 +324,7 @@ export default function SectionOverlay({
}}
>
{active === 1 && <HistoryView data={history} />}
{active === 2 && <SourcesView data={sources} />}
{active === 2 && <SourcesView data={sources} estimate={estimate} />}
{active === 3 && (
<AnalyticsView data={analytics} onNavigate={onNavigate} />
)}

View file

@ -0,0 +1,519 @@
"use client";
/**
* SourcesMap overlay "05 АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ" (SourcesView): real OSM
* basemap plotting every analog listing (estimate.analogs) + Rosreestr ДКП
* deal (estimate.actual_deals) that carries lat/lon, coloured per listing
* source with a legend, target-property pin + analog-radius circle.
*
* PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1
* app/page.tsx tree, dead in prod 307-redirected to /v2) copied rather
* than imported so v2 stays a self-contained port with no runtime dependency
* on the legacy tree, and no npm Leaflet dep (same as MapCard).
*
* Deal honesty: deals are geocoded to STREET CENTROIDS (backend
* scripts/geocode_deals_from_houses.py) several deals on the same street
* can collapse onto ONE point. Coincident points are grouped and fanned out
* with a small deterministic jitter + a "×N" badge instead of silently
* overlapping, and a visible caption states the approximation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. MapCard.tsx) */
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
import { safeUrl } from "@/lib/safeUrl";
import { tokens } from "./tokens";
import {
adMeta,
dealMeta,
deglueAddr,
fmtDate,
fmtDist,
fmtMonthYear,
numRu,
pluralRu,
sourceLabel,
tierLabel,
} from "./mappers";
const LEAFLET_VER = "1.9.4";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard.tsx) */
function loadLeaflet(): Promise<any> {
return new Promise((resolve, reject) => {
const w = window as any;
if (w.L) {
resolve(w.L);
return;
}
if (!document.querySelector(`link[data-leaflet]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = LEAFLET_CSS;
link.integrity = LEAFLET_CSS_SRI;
link.crossOrigin = "anonymous";
link.setAttribute("data-leaflet", "1");
document.head.appendChild(link);
}
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
if (existing) {
existing.addEventListener("load", () => resolve(w.L));
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
return;
}
const script = document.createElement("script");
script.src = LEAFLET_JS;
script.integrity = LEAFLET_JS_SRI;
script.crossOrigin = "anonymous";
script.setAttribute("data-leaflet", "1");
script.onload = () => resolve(w.L);
script.onerror = () => reject(new Error("leaflet load failed"));
document.body.appendChild(script);
});
}
// ── Per-source categorical palette ──────────────────────────────────────────
// dataviz-skill categorical order (fixed, never cycled), validated against the
// v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow
// slots (cian/yandex) dip below 3:1 text-contrast, so they are NEVER
// colour-alone — every legend entry and popup keeps its RU label alongside
// the dot. Leaflet needs raw hex strings, not CSS vars (see MapCard.tsx
// comment) — kept literal, same as the rest of this map's Leaflet colours.
const SOURCE_COLORS: Record<string, string> = {
avito: "#2a78d6",
cian: "#1baf7a",
yandex: "#eda100",
domklik: "#008300",
n1: "#4a3aa7",
};
const FALLBACK_SOURCE_COLOR = "#6b7280"; // неизвестный/прочий листинговый источник
const COLOR_TARGET = "#f59e0b"; // та же семантика, что и MapCard.COLOR_TARGET
// Сделки не соревнуются за 6-й категориальный оттенок — они отличаются ФОРМОЙ
// (ромб) и нейтральным тёмным цветом, а не ещё одним hue из палитры источников.
const COLOR_DEAL = tokens.ink;
const DOT_STROKE = "#fff";
function colorForSource(source: string | null | undefined): string {
if (!source) return FALLBACK_SOURCE_COLOR;
return SOURCE_COLORS[source.toLowerCase()] ?? FALLBACK_SOURCE_COLOR;
}
/** Безопасное экранирование для вставки в HTML popup (Leaflet вставляет как raw HTML). */
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === '"' ? "&quot;" : "&#39;",
);
}
interface GeoLot {
lot: AnalogLot;
lat: number;
lon: number;
}
function withCoords(lots: AnalogLot[]): GeoLot[] {
return lots
.filter(
(l): l is AnalogLot & { lat: number; lon: number } =>
typeof l.lat === "number" && typeof l.lon === "number",
)
.map((l) => ({ lot: l, lat: l.lat, lon: l.lon }));
}
/** Группирует точки с ИДЕНТИЧНЫМИ координатами (street-centroid stacking). */
function groupByCoord(points: GeoLot[]): GeoLot[][] {
const groups = new Map<string, GeoLot[]>();
for (const p of points) {
const key = `${p.lat.toFixed(6)},${p.lon.toFixed(6)}`;
const arr = groups.get(key);
if (arr) arr.push(p);
else groups.set(key, [p]);
}
return Array.from(groups.values());
}
// Небольшой детерминированный jitter — чисто визуальный (карта и так подписана
// как street-centroid approximation, честность не меняется). ~0.00018° на
// широте Екатеринбурга (~56.8°) ≈ 12-15 м; поправка на cos(lat) держит разлёт
// круглым, а не сплюснутым по долготе.
const JITTER_DEG = 0.00018;
function jitter(
lat: number,
lon: number,
index: number,
count: number,
): [number, number] {
if (count <= 1) return [lat, lon];
const angle = (index / count) * 2 * Math.PI;
const r = JITTER_DEG * (1 + Math.min(count, 8) * 0.15);
const latRad = (lat * Math.PI) / 180;
return [
lat + r * Math.sin(angle),
lon + (r * Math.cos(angle)) / Math.max(0.2, Math.cos(latRad)),
];
}
function analogPopupHtml(l: AnalogLot): string {
const href = safeUrl(l.source_url);
const exposure =
l.days_on_market != null ? ` · ${l.days_on_market} дн. на рынке` : "";
const dist = l.distance_m != null ? ` · ${fmtDist(l.distance_m)} от квартиры` : "";
return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
<div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
<div style="color:${tokens.muted2};margin:2px 0 6px">${esc(sourceLabel(l.source))} · ${esc(adMeta(l))}</div>
<div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} /м² · <b>${numRu(l.price_rub)} </b></div>
<div style="color:${tokens.muted};margin-top:4px">${esc(fmtDate(l.listing_date) + exposure + dist)}</div>
${
href
? `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer" style="display:inline-block;margin-top:8px;font-size:10.5px;color:${tokens.accent};border:1px solid ${tokens.line2};border-radius:4px;padding:3px 8px;text-decoration:none">Источник ↗</a>`
: ""
}
</div>`;
}
function dealPopupHtml(l: AnalogLot, stackCount: number): string {
const tier = l.tier ? tierLabel(l.tier) : "";
const stackNote =
stackCount > 1
? `<div style="margin-top:6px;color:${tokens.warn};font-size:10.5px">Ещё ${stackCount - 1} ${esc(
pluralRu(stackCount - 1, ["сделка", "сделки", "сделок"]),
)} на этой точке геопривязка по улице/кварталу, не по дому</div>`
: "";
return `<div style="font-family:${tokens.font.sans};font-size:12px;line-height:1.55;min-width:190px">
<div style="font-weight:600;color:${tokens.ink2}">${esc(deglueAddr(l.address || "—"))}</div>
<div style="color:${tokens.muted2};margin:2px 0 6px">Сделка · ${esc(sourceLabel(l.source))}${
tier ? ` · ${esc(tier)}` : ""
}</div>
<div style="color:${tokens.ink2}">${numRu(l.price_per_m2)} /м² · <b>${numRu(l.price_rub)} </b></div>
<div style="color:${tokens.muted}">${esc(dealMeta(l))}</div>
<div style="color:${tokens.muted};margin-top:4px">Период сделки: ${esc(
fmtMonthYear(l.listing_date),
)} (месяц, Росреестр)</div>
${stackNote}
</div>`;
}
function targetPopupHtml(estimate: AggregatedEstimate): string {
return `<div style="font-family:${tokens.font.sans};font-size:12px">
<div style="font-weight:600;color:${tokens.ink2}">Ваша квартира</div>
<div style="color:${tokens.muted};margin-top:2px">${esc(estimate.target_address ?? "—")}</div>
</div>`;
}
/** Ромбовидная divIcon для сделки + бейдж «×N» при стеке в одной точке. */
function dealDivIcon(L: any, count: number): any {
const size = Math.min(28, 14 + count * 2);
const badge =
count > 1
? `<div style="position:absolute;top:-7px;right:-7px;background:${tokens.ink2};color:#fff;font-family:${tokens.font.mono};font-size:9px;line-height:14px;min-width:14px;height:14px;border-radius:7px;text-align:center;padding:0 3px;box-shadow:0 0 0 1px #fff">${count}</div>`
: "";
const html = `<div style="position:relative;width:${size}px;height:${size}px">
<div style="position:absolute;inset:3px;background:${COLOR_DEAL};border:2px solid ${DOT_STROKE};border-radius:2px;transform:rotate(45deg);box-shadow:0 1px 2px rgba(0,0,0,.3)"></div>
${badge}
</div>`;
return L.divIcon({ html, className: "", iconSize: [size, size], iconAnchor: [size / 2, size / 2] });
}
// ── Card chrome — mirrors SourcesView's local style consts (v2 has no shared
// card component; each view file owns its own, see AnalyticsView/LeadForm). ──
const card: CSSProperties = {
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
overflow: "hidden",
};
const cardHeader: CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
};
const eyebrow: CSSProperties = {
fontSize: "8.5px",
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 5,
};
const cardTitle: CSSProperties = {
fontSize: "12px",
fontWeight: 600,
color: tokens.ink2,
};
const headerRight: CSSProperties = {
textAlign: "right",
fontSize: "9.5px",
color: tokens.muted2,
lineHeight: 1.6,
};
const legendRow: CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: 14,
alignItems: "center",
padding: "10px 18px",
borderTop: `1px solid ${tokens.lineSoft3}`,
fontSize: "10px",
color: tokens.body2,
};
const legendItem: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
};
const legendDot: CSSProperties = {
width: 9,
height: 9,
borderRadius: "50%",
display: "inline-block",
boxShadow: "0 0 0 1px #fff, 0 0 0 1.5px rgba(0,0,0,.15)",
};
const legendDiamond: CSSProperties = {
width: 8,
height: 8,
background: COLOR_DEAL,
border: "1.5px solid #fff",
boxShadow: `0 0 0 1px ${tokens.line2}`,
transform: "rotate(45deg)",
display: "inline-block",
};
const honestyFoot: CSSProperties = {
padding: "10px 18px",
fontSize: "9.5px",
color: tokens.hint,
background: "rgba(238,244,250,.5)", // тот же one-off tint, что и SourcesView KPI_BG
borderTop: `1px solid ${tokens.lineSoft3}`,
};
interface Props {
estimate: AggregatedEstimate | null | undefined;
}
export function SourcesMap({ estimate }: Props) {
const mapRef = useRef<HTMLDivElement>(null);
const [mapError, setMapError] = useState(false);
const analogPoints = useMemo(
() => (estimate ? withCoords(estimate.analogs) : []),
[estimate],
);
const dealPoints = useMemo(
() => (estimate ? withCoords(estimate.actual_deals) : []),
[estimate],
);
const hasTarget =
!!estimate &&
typeof estimate.target_lat === "number" &&
typeof estimate.target_lon === "number";
// Радиус круга = тот же максимум distance_m по аналогам, что уже заявлен в
// фильтр-чипе таблицы объявлений выше в этом overlay ("Расстояние ≤ N км",
// mappers.ts buildAdFilters) — карта не должна утверждать другой радиус.
const radiusM = useMemo(() => {
const ds = analogPoints
.map((p) => p.lot.distance_m)
.filter((d): d is number => d != null && Number.isFinite(d));
return ds.length ? Math.max(...ds) : null;
}, [analogPoints]);
const presentSources = useMemo(() => {
const set = new Set<string>();
for (const p of analogPoints) if (p.lot.source) set.add(p.lot.source.toLowerCase());
return Array.from(set);
}, [analogPoints]);
const totalPins = analogPoints.length + dealPoints.length + (hasTarget ? 1 : 0);
useEffect(() => {
if (!estimate || totalPins === 0) return;
let map: any = null;
let cancelled = false;
loadLeaflet()
.then((L) => {
if (cancelled || !mapRef.current) return;
const firstPin = analogPoints[0] ?? dealPoints[0];
let center: [number, number] | null = null;
if (hasTarget) {
center = [estimate.target_lat as number, estimate.target_lon as number];
} else if (firstPin) {
center = [firstPin.lat, firstPin.lon];
}
if (!center) return; // guarded by totalPins>0, но удовлетворяет TS
// scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay
// (SectionOverlay), захват колеса мыши иначе крадёт скролл панели.
map = L.map(mapRef.current, { scrollWheelZoom: false }).setView(center, 14);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 19,
}).addTo(map);
setTimeout(() => map && map.invalidateSize(), 120);
const bounds: [number, number][] = [];
// Объявления — цвет по источнику, стек в одной точке — jitter по кругу.
for (const group of groupByCoord(analogPoints)) {
group.forEach((p, i) => {
const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
bounds.push([lat, lon]);
L.circleMarker([lat, lon], {
radius: 7,
color: DOT_STROKE,
weight: 2,
fillColor: colorForSource(p.lot.source),
fillOpacity: 0.9,
})
.addTo(map)
.bindPopup(analogPopupHtml(p.lot));
});
}
// ДКП-сделки — ромб, нейтральный цвет, size+badge по числу в стеке.
for (const group of groupByCoord(dealPoints)) {
group.forEach((p, i) => {
const [lat, lon] = jitter(p.lat, p.lon, i, group.length);
bounds.push([lat, lon]);
L.marker([lat, lon], { icon: dealDivIcon(L, group.length) })
.addTo(map)
.bindPopup(dealPopupHtml(p.lot, group.length));
});
}
// Целевая квартира — крупный янтарный маркер + радиус-круг (та же
// семантика/паттерн, что и MapCard.tsx).
if (hasTarget) {
const ll: [number, number] = [
estimate.target_lat as number,
estimate.target_lon as number,
];
bounds.push(ll);
if (radiusM != null && radiusM > 0) {
L.circle(ll, {
radius: radiusM,
color: COLOR_TARGET,
weight: 1,
fillColor: COLOR_TARGET,
fillOpacity: 0.05,
// interactive:false — декоративный круг не должен перехватывать
// клики по пинам под ним (см. MapCard.tsx / issue #786).
interactive: false,
}).addTo(map);
}
L.circleMarker(ll, {
radius: 9,
color: COLOR_TARGET,
weight: 3,
fillColor: "#fff",
fillOpacity: 1,
})
.addTo(map)
.bindPopup(targetPopupHtml(estimate));
}
if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 16 });
}
})
.catch(() => setMapError(true));
return () => {
cancelled = true;
if (map) map.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- пересоздаём карту только при смене оценки/состава пинов, не на каждый ре-рендер
}, [estimate?.estimate_id, totalPins]);
// Честный empty state: нет оценки или нет геоточек вообще — карточку не
// показываем (то же поведение, что и MapCard.tsx), таблицы ниже остаются.
if (!estimate || totalPins === 0) return null;
return (
<article style={card}>
<div style={cardHeader}>
<div>
<div style={eyebrow}>РЫНОК · КАРТА</div>
<div style={cardTitle}>Аналоги и сделки на карте</div>
</div>
<div style={headerRight}>
{/* Fix #1 analogPoints is the top-10 display sample (only what the
backend returns coords for); estimate.n_analogs is the true total
used in the calc. "N из M" mirrors the deals-table "Показано N из
M" pattern so this never contradicts the market KPI band above. */}
Объявлений: <b style={{ color: tokens.ink2 }}>{analogPoints.length}</b>
{" из "}
{estimate.n_analogs}
{dealPoints.length > 0 && (
<>
{" · "}сделок: <b style={{ color: tokens.ink2 }}>{dealPoints.length}</b>
</>
)}
</div>
</div>
{mapError ? (
<div
style={{
padding: "24px 18px",
textAlign: "center",
color: tokens.muted,
fontSize: 12,
}}
>
Не удалось загрузить карту. Проверьте интернет-соединение.
</div>
) : (
<div
ref={mapRef}
style={{ height: 400, width: "100%", background: tokens.mapBg }}
aria-label="Карта аналогов и сделок"
/>
)}
<div style={legendRow}>
{presentSources.map((s) => (
<span key={s} style={legendItem}>
<span style={{ ...legendDot, background: colorForSource(s) }} />
{sourceLabel(s)}
</span>
))}
{dealPoints.length > 0 && (
<span style={legendItem}>
<span style={legendDiamond} />
Сделки (Росреестр)
</span>
)}
{hasTarget && (
<span style={legendItem}>
<span style={{ ...legendDot, background: "#fff", boxShadow: `0 0 0 2px ${COLOR_TARGET}` }} />
Ваша квартира
</span>
)}
</div>
<div style={honestyFoot}>
Сделки Росреестра геокодированы по улице/кадастральному кварталу, а не по
конкретному дому совпадающие точки визуально разнесены и подписаны
«×N». Клик по пину детали.
</div>
</article>
);
}
export default SourcesMap;

Some files were not shown because too many files have changed in this diff Show more