Commit graph

1029 commits

Author SHA1 Message Date
f6b3b77e11 feat(sf): EKB geoportal WFS client (#1067 C7)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 5m50s
CI / backend-tests (pull_request) Successful in 5m46s
Клиент муниципального геопортала ЕКБ (ИСОГД/Geometa GeoServer, 1149 слоёв, public,
без auth). Primary = OGC WFS GetFeature /api/gis/ows/3/portal/wfs (стабильнее
reverse-eng urbanCard, один клиент ко всем слоям): ПЗЗ терзоны/подзоны, функц.зоны
генплана (#1058), КРТ (#1060), ППТ/ПМТ, ЗОУИТ. features_at_point (CQL INTERSECTS),
features_in_bbox (CQL BBOX), zone_index_at (crosswalk координата→индекс ПЗЗ для #1059).
EPSG:4326 (X=lon Y=lat). НЕ GetFeatureInfo (reproject-баг GeoServer).
Источник: vault research/EKB_Geoportal_WFS_Construction_Intel_Jun06.

Refs #1067 (C7). Next: рич ПЗЗ-карточка urbanCard (C8), parcel-резолв (D9).
2026-06-06 21:32:15 +03:00
c2e0428803 feat(nspd): get_feature_info FEATURE_COUNT=10 — multi-feature per point (#1080)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m14s
Deploy / build-worker (push) Successful in 2m24s
Deploy / deploy (push) Successful in 1m21s
get_feature_info() шлёт WMS FEATURE_COUNT=10 (новый параметр feature_count, default 10). NSPD по умолчанию отдаёт 1 фичу/точку → перекрывающиеся зоны одного слоя теряются (37581 «иные ЗОУИТ» на ЕКБ = 2 фичи). Tile-геометрия не тронута. +2 теста (multi-feature parse, override), существующий обновлён.

Refs #1067.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 18:18:07 +00:00
22fffb9878 feat(parcels): typed AnalyzeResponse + run-history read endpoints (#961)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m20s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
#992: attach response_model=AnalyzeResponse to POST /{cad_num}/analyze. Model
uses extra="allow" so any result_payload key not explicitly modeled is preserved
in the 200 response (no silent drops that would break Site Finder), and ALL
fields are Optional so the #93 202 fetch-stub serializes without a 500.

#994: add GET /{cad_num}/runs (light summary list, empty 200 not 404) and
GET /runs/{run_id} (full row incl result, 404 if missing), backed by
list_runs_for/get_run in analysis_runs.repository (psycopg v3 CAST(:x AS type)).
Routes ordered before /{parcel_id} so /runs/{run_id} is not shadowed.

Closes #992. Closes #994. Refs #961.
2026-06-06 18:13:34 +00:00
813c4782a8 feat(tradein): rebrand default product «Trade-In» → «Мера» + new mark (#1079)
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
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 1m40s
Deploy Trade-In / deploy (push) Successful in 44s
Ребрендинг дефолтного (не white-label) продукта «Trade-In» → «Мера» + inline-SVG логотип (буква М над размерной линией). Topbar MeraMark заменяет TI-бейдж, wordmark/крошки/футер/meta → «Мера». White-label бренды клиентов, сервисный термин «трейд-ин» в прозе OfferCard/HeroTransparency и code-идентификаторы /trade-in/ не тронуты. tsc 0 + build green.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 18:11:35 +00:00
3120c902c6 feat(sql): ird_overlays durable overlay store for ИРД geometry (#1078)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 27s
Deploy / build-worker (push) Successful in 27s
Deploy / deploy (push) Successful in 58s
Миграция data/sql/132_ird_overlays.sql — durable overlay-store геометрии ИРД-ограничений участка (ЗОУИТ/ПЗЗ-границы/ОКН/ООПТ), снятых из НСПД WMS. geometry(Geometry,4326)+GIST, UPSERT-ключ (source_layer_id, geom_data_id), nullable content_* (резолв C8/D9). Идемпотентна (IF NOT EXISTS + guarded DO), пустая таблица. Foundation для #1067 B5 harvest-воркера.

Refs #1067.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 18:08:40 +00:00
38d948f078 fix(nspd): list_layers parse flat layers[] response, not children-tree (#1077)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m26s
Deploy / deploy (push) Successful in 1m9s
Deploy / build-worker (push) Successful in 2m27s
NSPDClient.list_layers() парсил _walk_layer_tree по id/children, но НСПД layers-theme-tree отдаёт слои в плоском layers[] с ключом layerId → list_layers() молча возвращал [] для любой темы. Fix: сначала плоский data["layers"], иначе fallback на _walk_layer_tree; defensive None-guard на missing id. +тест на flat-форму, backward-compat сохранён.

Refs #1067.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 17:51:14 +00:00
d9c2157d02 feat(site_finder): velocity coverage gap-fill via spatial+name fallback (#968 949-A)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m34s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m57s
CI / backend-tests (push) Successful in 5m43s
Deploy / deploy (push) Successful in 1m9s
CI / backend-tests (pull_request) Successful in 5m56s
For competitors missing from objective_complex_mapping, bridge to objective
velocity: nearest objective-bearing complex within 200m whose name tolerantly
matches → complex_id → objective_lots.project_name → objective_corpus_room_month.
Prod-measured: 148 → 329 mapped competitors (2.2×), validated by EXPLAIN+execute.

- nearest_cx CTE UNION'd into mapped (gap-fill only; primary 148 byte-identical,
  no double-count — UNION dedups, DISTINCT ON = one complex per competitor)
- candidates restricted to complexes WITH objective_lots.project_name (data-bearing):
  naive nearest-any gave +37; data-bearing nearest gives +181 (the real win)
- empty-comm_name guard avoids LIKE '%%' spatial-only leak
- velocity.py unchanged (its has_mapping coverage-gate is a separate concern);
  parcel.py unchanged (relevance_weight already satisfies DoD)

Blast radius narrow: affects relevance_weight (via stage_at_horizon) for
newly-mapped competitors; §22 market-pulse velocity (velocity.py) untouched.
deep-code-reviewer ⚠️ minor (approved); review items addressed. Part of EPIC #949.
2026-06-06 22:05:15 +05:00
b77e856d89 fix(frontend): bind Next standalone to 0.0.0.0 so healthcheck passes
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m46s
Deploy / deploy (push) Successful in 50s
Docker auto-sets HOSTNAME to the container id; Next standalone's server.js
binds process.env.HOSTNAME, so it listened on the container hostname and the
compose healthcheck (TCP 127.0.0.1:3000) was refused → frontend container
chronically "(unhealthy)" (cosmetic: Caddy reaches it by service name and the
site serves 200). Pin HOSTNAME=0.0.0.0 — the canonical Next.js Docker fix.
Diagnosed on prod 2026-06-05.
2026-06-06 20:06:52 +05:00
37837e72f4 ci(forgejo): add pytest gate for the main backend suite (CI-rehab 3/3)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
Forgejo runs only .forgejo/workflows/* — the .github pytest gate never
executed here, so backend changes merged + deployed untested (live bug
#994 district 500 shipped uncaught). Add a real gate: ruff check + the full
backend pytest suite (1687 passed) on PRs to main and feature-branch pushes,
scoped to backend/** + data/sql/** via paths-filter. Mock-only lane (no
postgres service): the one real-DB test self-skips via a connectivity probe;
WeasyPrint PDF tests RUN thanks to installed libpango. uv via official
installer (not setup-uv, PEP 668), uv sync --frozen against the committed lock,
TESTING=1 to activate the test-mode RBAC bypass.

NOTE: making this a HARD required check + having the auto-merge bot consult
check status needs Forgejo branch-protection config (human action) — until
then the gate is visible but advisory.

Refs #944.
2026-06-06 15:04:18 +00:00
a87a69af7c feat(analyze): expose competitor + pipeline lat/lon for map layers (#999)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m25s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m9s
Add nullable lat/lon (EPSG:4326, 6 dp) to /analyze competitors[] and
pipeline_24mo.top_objects[] so the frontend can plot Leaflet markers. Coords
come from domrf_kn_objects.latitude/longitude (same source as distance_m).
Purely additive: no existing field/shape changed. Frontend map layers follow
in a separate PR. Part of EPIC #958 (958-B4).
2026-06-05 16:44:32 +05:00
f7eb98b971 fix(site-finder): handle 202 on-demand geometry in analyze + error boundaries (#1001)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m52s
Deploy / deploy (push) Successful in 1m1s
Critical demo-blocker: POST /analyze returns 202 {status:fetching} for parcels
whose geometry isn't cached yet (#93 on-demand НСПД fetch). useParcelAnalyzeQuery
treated 202 as success → the stub (no score/competitors) reached render → Section 3/4
threw TypeError → white screen (no error boundary). Repro confirmed on prod.

- useParcelAnalyzeQuery: 202-aware — poll /fetch-status (2s, 2-min cap), re-POST
  on ready (status-checked, no stub on 202 race), throw HTTPError on
  not_in_nspd/failed/invalid_format; horizon preserved in both POSTs; retry: false
- error boundaries: app/site-finder/analysis/[cad]/error.tsx + app/error.tsx —
  no white screen ever; calm RU message in prod, error detail in dev
- Section3/Section4: guard competitors/score against partial payloads
- CadInput: drop hardcoded default cad (empty + placeholder), raw hex → tokens

Map parcel-select already fixed by 3cea915. Part of EPIC #958.
2026-06-05 16:12:21 +05:00
740210d9ea feat(site-finder): horizon selector (6/12/18) on analysis screen (#996)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m0s
Segmented control «Горизонт прогноза» driving the analyze query (?horizon=)
and Section 6's target highlight. Default 12.

- HorizonSelector: radiogroup + a11y radios, tokens-only, tabular-nums,
  disabled while a re-analyze is in flight
- useParcelAnalyzeQuery(cad, horizon=12): horizon in queryKey + ?horizon=;
  keepPreviousData so switching horizon doesn't blank the page to loading
- AnalysisPageContent: horizon state + forecast-poll invalidation on change
- Section6Forecast/ForecastHorizonsBlock: selectedHorizon → target row («Целевой»)

Part of EPIC #958.
2026-06-05 15:42:58 +05:00
4ec762a202 feat(site-finder): forecast Section 6 (прогноз/сценарии/confidence) on analysis screen (#998)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m56s
Deploy / deploy (push) Successful in 1m4s
Renders the §22 SiteFinderReport (future_market/scenarios/confidence) as a new
Section 6 on the modern analysis page (/site-finder/analysis/[cad]), mirroring the
Section1-5 pattern. Polls GET /parcels/{cad}/forecast (202 pending → 200 ready) via
TanStack Query. Render-only (horizon selector = separate #996).

- 6.1 прогноз по горизонтам (deficit_index, MOI, demand/supply, ставка)
- 6.2 сценарии base/aggressive/conservative + rate_path
- 6.3 уверенность: level pill + явный список drivers (confidence.factors)
- types/forecast.ts typed against real prod report; fixture parcel-forecast.json (run #68)
- reuses design tokens + Badge/KpiCard; advisory disclaimer always shown

Part of EPIC #958. Deps 955-A2/A4 (backend §13, merged).
2026-06-05 14:09:55 +05:00
19584e0249 feat(parcels): wire §22 forecast into analyze endpoint (3b-ii, #995)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m14s
Final forecast wiring — §22 reaches the API.

- horizon query param on POST /{cad}/analyze ({6,12,18}, default 12; 422 before any
  DB/compute work otherwise).
- best-effort fire-and-forget forecast_site_finder_report.delay(cad, horizon,
  created_by) after persist (lazy import, no api↔workers cycle); Celery/Redis down →
  forecast.status='unavailable', analyze still 200 (never blocks/500s). Adds additive
  result_payload['forecast'] = {status: pending|unavailable, horizon}.
- new read-only GET /{cad}/forecast: latest schema_version='1.0' run → 200
  {status:ready, run_id, created_at, report} else 202 {status:pending}; graceful on DB
  error (202, never 500). Mirrors fetch-status RBAC/style.

Additive only — existing analyze response keys unchanged. code-review APPROVE
(analyze uncrashable + additive; 422 pre-flight; GET reads '1.0' not 'analyze-1.0').
96 api tests pass; ruff clean. Refs #995 #992 #961.
2026-06-05 09:22:57 +05:00
c94810f3a7 feat(forecast): forecast_site_finder_report Celery task + schema-filtered run read (3b-i)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m11s
build_site_finder_report (§22) takes ~30-180s → runs in a background Celery task,
not inline on the sync /analyze endpoint.

- repository: latest_run_for gains keyword-only schema_version (default None keeps
  v_analysis_runs_latest behavior, backward-compat); when given, reads base
  analysis_runs filtered by schema_version ORDER BY created_at DESC LIMIT 1 — fetches
  the latest analyze-1.0 site-analysis run even when newer 1.0 (§22) rows exist on top
  (index-served via 127's (cad_num, created_at DESC)).
- new workers/tasks/forecast.py::forecast_site_finder_report: reads latest analyze-1.0,
  runs the §22 orchestrator, persists SiteFinderReport.as_dict() as a 1.0 run via
  persist_analysis_run. Graceful: no base run / compute error → logger + return None
  (worker not crashed). time_limit=900/soft=840 (no global limit). Registered in include.

Prod-confirmed: analyze-1.0 result carries the full analyze dict (competitors+district)
→ orchestrator input valid. Endpoint trigger (3b-ii) + §9.x untouched. 943 tests pass;
code-review APPROVE (contracts verified vs real as_dict(); status done→complete normalized,
no IntegrityError). Refs #994 #961.
2026-06-05 09:02:15 +05:00
a0e61a38b4 feat(forecasting): §9.x→§22 orchestrator + fix supply-side district resolution (3a)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m25s
Deploy / deploy (push) Successful in 1m7s
Add build_site_finder_report (orchestrator.py): computes the §9.x layers (market
metrics, supply layers, future-supply pressure, demand/supply forecast, scenarios,
score card, special indices, recommendation overlay) with their heterogeneous
signatures and feeds the PURE assemble_report → §22 SiteFinderReport. Default segment
= modal competitor class; each §9.x call _safe_call-wrapped (graceful). Standalone —
no endpoint/Celery/persistence (that is 3b).

Prod ground-truth of the orchestrator surfaced a false-BUY bug: future_supply
(compute_future_supply_pressure) read the mixed-vocab persisted view
v_supply_layers_latest by a SCALAR admin district_name, missing all Layer-1
micro-keyed rows → admin parcel (Кировский) got supply=0 → false +1.0 deficit →
'Строить: недонасыщен' headline despite ~45k competing units. Fix: resolve
admin→micros, filter district_name = ANY(CAST(:names AS text[])) where names =
micros (L1) + admin (L2/L3), with :has_district EKB-wide guard (extends PR #1054's
resolver to the persisted-view path it missed). future_supply is the only
v_supply_layers_latest consumer on the forecast path (verified).

Prod after: Кировский supply 0→~42953, deficit +1.0→−1.0 (honest oversupply),
MOI 0→116.6, false-BUY headline gone, overall 0.734→0.42. 80 module tests pass
(signature-trap + resolver-regression guards genuine); ruff clean. Refs #961 #969.
2026-06-05 08:21:04 +05:00
01a74ade7a feat(forecasting): add months-of-inventory (MOI) to §9.8 demand-supply forecast
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-worker (push) Successful in 2m17s
Deploy / deploy (push) Successful in 1m12s
deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year
supply stock → log-ratio clamps) → zero discriminating power, though the oversupply
is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate
absorption standard, as an additive non-saturating companion that DISCRIMINATES
(Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly
as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common
and MOI is the discriminating companion (no recalibration). _gross_supply extract-method
(single source of truth; _project_supply behavior byte-identical, code-review-verified).
Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict.
Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1.
Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
2026-06-05 07:36:59 +05:00
681a922d99 feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m7s
/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.

Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).

Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
2026-06-05 07:03:37 +05:00
b83ffedec3 feat(sql): add ekb_district_alias micro->admin vocab bridge (131)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 27s
Deploy / build-worker (push) Successful in 27s
Deploy / deploy (push) Successful in 58s
§9.x forecast metrics filter objective_lots.district (informal micro-districts)
but /analyze resolves the official admin district from ekb_districts → admin
name matched 0 rows → silent empty forecast (market_metrics('Кировский')→0).
Add a curated micro→admin alias table seeding all 35 micro values to their
primary admin (8 EKB admins). Curated not spatial: complex_id join hits only
~28% of rows and complexes is multi-city, so ST_Contains scatters micros
uniformly. 4 cross-city values (Н.Тагил/Каменск) contained in one noted bucket.
Coverage: 8/8 admins resolve, 35/35 mapped, 0 unmapped. Idempotent + additive
(CREATE TABLE IF NOT EXISTS + INSERT ON CONFLICT DO NOTHING); applied to prod
+ registered in _schema_migrations. Resolver/fallback documented in header;
market_metrics wiring is Step 2 (separate PR). Refs #969 #949.
2026-06-05 06:39:50 +05:00
4844847cae fix(affordability): calibrate key_rate->market mortgage spread to 4.5pp
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m40s
Deploy / deploy (push) Successful in 1m13s
_KEY_RATE_MARKET_SPREAD_PP was a 0.0 placeholder, so §7.9 affordability payments
used the bare CBR key_rate (~14.5%), understating borrower cost and OVERSTATING
affordability. Calibrate to 4.5pp from the prod anchor (macro_indicator
mortgage_rate_primary_domrf 19.125% @ 2026-04-19 vs key_rate ~14.5% -> observed
~4.6pp, rounded conservatively; inside the typical RF 3-5pp band), so
rate_used = key_rate + 4.5 ~= 19% matches the directly-observed market primary
mortgage rate. Makes affordability LESS optimistic / more accurate. Docs + tests
updated to the symbolic spread (new regression anchor pins spread==4.5 and
key_rate 14.5 -> ~19.0); rate_kind/graceful-fallback semantics unchanged.
Forecasting suite 841 passed; ruff clean.
2026-06-04 17:21:29 +05:00
cc66f51863 fix(backtest): add binomial significance gate to §9.6 verdict
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 28s
Deploy / build-worker (push) Successful in 28s
Deploy / deploy (push) Successful in 58s
The OOS verdict flagged a variant 'candidate to promote' on hit-rate >= 0.5+margin
+ lag_stable alone. On thin data this over-claims: Source A Almon-ADL scored 6/10
(0.60) lag-stable and was flagged as signal, but P(X>=6|10,0.5)~=0.377 -- a coin
flip. Live ground-truth confirmed no signal (full-sample R2~=0.003, wrong sign).

Add exact stdlib-only one-sided binomial _binom_sf_ge + _VERDICT_ALPHA=0.05 and
require P(X>=hits|n_test,0.5) < alpha in both verdict() and cross_source_verdict()
on top of the effect-size margin. hits recovered exactly as round(hit_rate*n_test)
(n_test==scored invariant; no evaluator shape change). Verdict text now states
n_test + the binomial p on pass and fail. Evaluator/estimator math and the
read-only SELECT discipline untouched. Refs #978.
2026-06-04 16:42:45 +05:00
9cffe3c9ec feat(forecasting): wire Almon-ADL rate estimator into §9.6 consumers (#978)
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m37s
Deploy / deploy (push) Successful in 1m9s
Deploy / changes (push) Successful in 5s
Backtest (OOS directional hit-rate): single-best-lag compute_rate_sensitivity
is directionally noise (0.148 Source B EKB-wide, lag-unstable); the Almon
distributed-lag estimator (compute_district_rate_regression) is strictly less
noisy on every tier (0.407 Source B / 0.60 survivorship-free Source A,
lag-stable). Add a thin adapter compute_rate_regime_sensitivity mapping
DistributedLagFit onto the existing RateSensitivity contract (beta=long-run
sum-beta, confidence regression->medium / fallback->low, district=None->low and
no call) and repoint the three consumers (demand_normalization, product_scoring,
demand_supply_forecast). Magnitude bounded by the existing [0.5,1.2] clamp.
Reversible; compute_rate_sensitivity kept for the backtest. Consumer tests
repointed to the real Almon path (mutation-verified genuine) + adapter unit
tests + end-to-end fallback degradation. Forecasting suite 840 passed; ruff clean.
2026-06-04 10:33:31 +00:00
692f468010 feat(backtest): validate #978 Almon-ADL + #979 seasonal as OOS variants
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 32s
Deploy / build-worker (push) Successful in 33s
Deploy / deploy (push) Successful in 59s
Extend the read-only §9.6 rate-sensitivity OOS harness with two opt-in
candidate-method variants so any wiring decision is evidence-based:
- --almon: evaluate_oos_almon, Almon distributed-lag (regression.fit_almon_dl),
  fit on TRAIN only, point-in-time sum_j beta_j*drate[t-j] prediction.
- --deseasonalize: train-only month-of-year factors (normalize.seasonal_factors)
  divided out before log_diff, then the existing best_lag evaluator.
Both pin the fit to _time_ordered_split(n_train); no look-ahead leakage
(adversarial tests assert the train fit is byte-identical under test corruption).
Default path (best_lag/raw) is byte-identical to before. 88 tests pass, ruff clean.

Prod OOS findings (directional hit-rate, coin-flip 0.50, bar 0.55+lag-stable):
- #979 deseasonalize: neutral (B 0.148->0.148, A 0.40->0.40) -> keep advisory.
- #978 Almon-ADL: dominates best_lag (B 0.148->0.407 lag-stable; A 0.40->0.60,
  clears coin-flip+margin) -> candidate to promote from advisory.
2026-06-04 14:55:21 +05:00
6a32acb3aa fix(competitors): size-weight avg velocity by flats_total (#949 audit, option B)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m32s
Deploy / build-worker (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m56s
weighted_avg_velocity was a naive mean despite the name — a 500-flat ЖК weighed
the same as a 20-flat one. Now count-weighted by flats_total (sql.md AVG
principle): Σ(velocity*flats_total)/Σ(flats_total). Competitors with unknown
flats_total are excluded from weights; if sizes are unknown for ALL, graceful
fallback to the simple mean (den>0 guard). Field name + API contract UNCHANGED
(zero consumer ripple — traced: only CompetitorsSummary, no frontend ref).

Tests: equal sizes → weighted==naive (existing 6.0 stays); NEW test with
500-flat@40 + 20-flat@2 → 38.54 (not naive 21.0), proving the weighting.
2026-06-04 14:05:20 +05:00
b4eb6a8ad5 fix(forecasting): honest USP gate (di>0) + unit-explicit coverage fraction
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m16s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m3s
Found by read-only services audit.
- recommendation._usp_from_deficits: skip di<=0 so «стройте его» is never emitted
  for OVERSUPPLIED formats; all-surplus top-K → [] (no white-space niches).
  Aligns with product_scoring._count_positive_usp (di>0). Was: «Дефицит формата
  X — стройте его» for a surplus format, reaching PDF/Excel USP-ниши.
- report_assembler._domrf_coverage: drop ambiguous >1.0 percent-guess; normalize
  per-branch (analyze pct /100, supply_layers fraction as-is). Sub-1% coverage
  (0.8%) no longer read as 80% → no inflated confidence in the near-zero-coverage
  case §15 flags. tests for both + end-to-end no-inflation. 241+148 pass.
2026-06-04 13:25:25 +05:00
5f60668642 fix(nspd-geo): coerce geom to MultiPolygon + wide cad_buildings schema in on-demand ingest
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m8s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m19s
REAL code-level cause (prod-confirmed, 2 parcels: "Geometry type (Polygon) does
not match column type (MultiPolygon)") of single-contour parcels stuck "fetching"
— complements migrations 129/130 (which fixed the cad_parcels NOT NULL chain).

F1: _save_parcel/_save_quarter now ST_Multi() the geom so single-contour Polygons
fit the MultiPolygon columns (migr 93/58), mirroring the bulk_harvest paths.
F2: _save_building rewritten to the wide cad_buildings schema (migr 92):
quarter_cad_number (was quarter_cad_num → UndefinedColumn), _safe_int(floors)
(range strings → NULL), source='nspd' NOT NULL.
F3: upsert_features wraps each feature in begin_nested() SAVEPOINT so one bad
feature no longer aborts the whole quarter snapshot (backend.md rule; mirrors
grid-walk). New tests/workers/test_nspd_geo.py + savepoint tests. 89 passed.
2026-06-04 13:11:47 +05:00
3cea915e48 feat(site-finder): clickable parcel markers open analysis from the map
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m7s
Marker click opens an interactive Leaflet popup with a primary "Открыть анализ"
action that navigates to /site-finder/analysis/<cad> (encodeURIComponent —
matches the [cad] route's single-decode contract) and records the visit in
RecentParcels. Hover tooltip enriched with status + actionable hint. Existing
ParcelDrawer preview kept, reachable via the popup's secondary "Подробнее".
ESLint + tsc clean.
2026-06-04 12:50:37 +05:00
51966b38c8 fix(db): default cad_parcels.source in trigger (on-demand ingest 2nd NOT NULL gap)
Some checks failed
Deploy / build-worker (push) Successful in 39s
Deploy / deploy (push) Has been cancelled
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 36s
After #129 fixed quarter_cad_number, the on-demand НСПД ingest of a real
not-pre-loaded parcel hit the NEXT cad_parcels NOT NULL column: source
(NotNullViolation) → geometry transaction rolled back → POST /analyze still
stuck. Repro 66:41:0610029:83 (job 57: quarter derived by #129, source null).

Migration 130: extend the #129 BEFORE INSERT/UPDATE trigger function to also
default source→'nspd' when NULL (column DEFAULT can't help — INSERT binds
explicit NULL; trigger overrides it). Explicit sources ("search"/"wms_grid_walk")
respected — only NULL is filled. cad_parcels NOT NULL surface now fully covered
(cad_num/quarter[#129]/category_id[literal]/source[#130]/fetched_at+updated_at[NOW]).
Dry-run-verified on prod: null quarter+source → 66:41:0610029 + nspd, INSERT ok.
2026-06-04 12:49:46 +05:00
45d5a338ea fix(db): auto-derive cad_parcels.quarter_cad_number trigger (on-demand ingest 500)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 29s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 58s
PROD bug: on-demand НСПД ingest (bulk_harvest.upsert_parcel, for parcels NOT
pre-loaded) intermittently inserts quarter_cad_number=NULL at runtime →
NotNullViolation → geometry never persists → POST /analyze for a real new parcel
stuck "Геометрия загружается из НСПД…" forever (re-enqueues each call). Repro:
66:41:0610029:83 (real НСПД parcel; failing row had valid geom + props, only
quarter_cad_number null). Code derives quarter_cad but NULL still reaches the
INSERT via some runtime path (grid-walk).

Migration 129: BEFORE INSERT OR UPDATE trigger on cad_parcels — when
quarter_cad_number IS NULL and cad_num has >=3 colon-segments, derive it
(first 3 segments: 66:41:0610029:83 → 66:41:0610029). Fixes ALL write paths at
the DB level (quarter_cad_number is a pure derivation of cad_num). <3-segment
cad_num intentionally left NULL → NOT NULL still rejects genuine garbage (no
masking). Coexists with m.92 set_updated_at trigger. Idempotent.

Dry-run-verified on prod catalog: null-quarter insert → derived 66:41:0610029;
garbage <3seg still rejected. Verification = re-fetch 0610029:83 post-deploy.
2026-06-04 12:19:21 +05:00
3945d54e3b fix(#945): indicator-aware default region in macro reader + document debt data-quality
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m53s
Deploy / build-worker (push) Successful in 3m19s
Deploy / deploy (push) Successful in 1m10s
FOLLOW-UP (Leha: not a bug, follow-up).

get_macro_series / get_latest_macro defaulted region='rf', but CBR mortgage_*
series live under 'sverdl' in macro_indicator (key_rate under 'rf'). Only caller
passes region explicitly so no active leak, but a future caller omitting region
would silently get None — latent footgun. region now str|None; when None →
_canonical_region (mortgage set sourced from _CBR_SERIES_MAP, single source of
truth). Explicit region always wins → existing callers unchanged. Region-binding
tests added. 159 tests pass.

Documented (NOT code-fixed — upstream data-quality): mortgage_debt /
mortgage_overdue stay empty in macro_indicator because cbr_mortgage_series.period
holds value-like garbage (e.g. '10054588.0') for the Debt series — corruption
from the upstream CBR-XLSX scraper that built domrf.db (imported verbatim by
44_import_anton_db.py), NOT an ingest bug. 123_macro_indicator.sql correctly
skips unparseable periods. Needs a separate scraper re-ingest (idempotent
backfill once period is valid). Refs #945
2026-06-04 11:49:41 +05:00
9a845646fd fix(#978): train-only detrend in rate backtest + Almon distributed-lag regression
All checks were successful
Deploy / deploy (push) Successful in 1m9s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m20s
REOPENED 951-B §9.6.
PART A: fix look-ahead leakage in backtest_rate_sensitivity --detrend. The
ln(units) trend was fit over train+test then split, so test data shaped the
detrend and inflated the OOS hit-rate. _detrend_log now takes fit_n; backtest_tier
fits the trend on TRAIN months only (same split evaluate_oos uses) and projects
(a,b) point-in-time onto test. Default fit_n=None preserves prior behaviour.

PART B (DoD): new app/services/forecasting/regression.py — Almon polynomial
distributed-lag (deg 2) of Δln(district demand) on Δkey_rate lags 0..6 via
OLS-on-Almon-regressors (numpy lstsq) + per-lag reconstruction + manual
Newey-West HAC SEs (NO statsmodels). Output {best_lag_months, coef=long-run
multiplier, x_pct, r2, n, per_lag_coef, hac_se,...}; gate mirrors _elasticity_coef
(n<30 OR R²<0.1 OR Σβ≥0 → fallback); §9.6 phrase from the lag shape. ADVISORY,
shipped standalone (integration point documented), NOT wired — protects the live
compute_rate_sensitivity consumers.

125+31 tests (synthetic known-lag recovery, HAC computed/differs-from-OLS,
fallback gating, no-leakage detrend). ruff clean. Refs #978
2026-06-04 11:39:32 +05:00
3ecfc2d7dc feat(forecasting): seasonal (month-of-year) demand normalization (#979)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m16s
Deploy / build-backend (push) Successful in 1m28s
Deploy / deploy (push) Successful in 1m6s
REOPENED — normalize.py was never created; only rate-regime discount existed.
New backend/app/services/forecasting/normalize.py with normalize_demand(series):
multiplicative month-of-year deseasonalization of the raw monthly demand
SalesSeries (§9.4). Pure/deterministic; min-data guard (<2 full years / empty
month / overall_mean<=0 → factor 1.0, no divide-by-zero, no thin-data noise).
Exposes seasonal factors for explainability. Synthetic unit test: seasonality
removed (month means equalised), flat unchanged, thin/empty/all-zero safe.

DoD (module + doc + test) MET. Production wiring into
rate_sensitivity._align_sales_deltas DEFERRED (documented TODO): deseasonalizing
the short rate-driven series perturbs the recovered β/lag on current data —
needs a points-per-month gate / joint seasonal+rate estimation + backtest before
wiring. Forecast stack is advisory regardless. Refs #979
2026-06-04 11:19:50 +05:00
9ff80f3ac0 fix(forecasting): #980 strongest deficit→deficit_index +1.0; #981 MAI uses CBR key rate
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / deploy (push) Successful in 1m7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m39s
Deploy / build-worker (push) Successful in 2m39s
REOPENED #980: when effective competing supply is exhausted under positive demand
(projected_supply<=0, demand>0), deficit_index now caps to +1.0 (peak of [-1,+1])
instead of None. balance_ratio stays None (demand/0 undefined), but the strongest
build signal no longer reads downstream as thin data (market_fit fell to 0.5,
what_to_build dropped the cell). No-signal (supply<=0 AND demand<=0) stays None.

REOPENED #981: MAI now uses CBR key rate (macro_indicator key_rate/rf via
get_monthly_macro) as the market borrowing-cost proxy (~16-21%) instead of the
subsidized weighted rate (~7.83%), per §7.9 DoD. rate_kind='key_rate_proxy'.
If key_rate absent → rate_kind='market_unavailable' (no silent subsidy fallback).
Income (#946) still missing → payment_to_income None, confidence low.

778 forecasting tests green. Refs #980 #981
2026-06-04 11:10:26 +05:00
33d1a63bc8 fix(exporters): read real SiteFinderReport keys in PDF/Excel (#989, #991)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m18s
Deploy / build-worker (push) Successful in 6m57s
Deploy / deploy (push) Successful in 1m19s
REOPENED. PDF + Excel exporters read non-existent dict keys, so demand/supply/
scenario columns silently rendered "—". Tests passed only because the fixtures
were stale (hand-typed the same wrong keys → fixture agreed with buggy exporter).

- future_market: demand/supply → projected_demand_units/projected_supply_units
- scenarios: drop non-existent per-scenario "overall"; show primary-horizon
  deficit_index from ScenarioForecast.forecasts (scoring.overall was NOT broken)
- Excel #991: add missing future_supply (index + breakdown) + confidence.factors
  sections; add future_supply to PDF for parity
- tests: rebuild forecast/scenario fixtures from real DemandSupplyForecast /
  ScenarioForecast as_dict(); contract-key regression guards fail on key-drift
  (verified: reintroducing old keys fails the new tests). 28 passed.

Refs #989 #991
2026-06-04 10:56:51 +05:00
2b3759af6a fix(market-metrics): count window sales by contract_date, not 17-day history (#949 CRITICAL)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m34s
Deploy / deploy (push) Successful in 57s
REOPENED. _SALES_WINDOW_SQL derived "sales in window" from objective_lots_history
snapshots, but history is only ~17 days deep — every currently-sold lot had a
sold-snapshot in the window, so window-sales collapsed into the entire cumulative
sold stock (Автовокзал 6mo: 33,245 vs real ~2,308). Inflated absorption_rate
(~235%/mo with confidence=high), months_of_supply, unit_velocity, liquidity,
demand_concentration → contaminated forecast #950/#952.

Count window sales directly from objective_lots by contract_date in the window
(the real sale date — present on 100% of sold lots: 41,091/41,091). Return
contract of _query_sales_window unchanged (units/area/by-room ROLLUP); downstream
formulas untouched. Removed the now-dead objective_lots_history JOIN/CTE.
Regression test: lots sold outside window (contract_date out of range) not counted
(41,091 cumulative vs 2,308 window → absorption 2.35→0.04). 288 tests green.

Verification = prod compute_market_metrics(Автовокзал) post-deploy. Refs #949
2026-06-04 10:46:50 +05:00
ed3b302d57 fix(supply-layers): thread dev_group_name into L3 upsert key (#970 CRITICAL)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m4s
REOPENED. L3 future-supply rows are computed per (district_name, dev_group_name)
but dev_group_name was never a key column — only embedded in method text. With
complex_id/obj_class NULL for L3, every dev_group of a district collapsed to one
upsert key → ~95.6% loss. Ground-truth (Академический, prod): should be 13,808
units / 15 dev_groups / 54 objects; only 1 row / 607 units survived.

Migration 128: ADD COLUMN supply_layers.dev_group_name TEXT + rebuild
uq_supply_layers_logical to (layer, district_name, complex_id, obj_class,
dev_group_name, source, snapshot_date) NULLS NOT DISTINCT (L1/L2 dev_group_name
NULL stays transparent → their dedup unchanged; L3 distinct groups no longer
collapse). Dry-run-verified vs prod catalog (applies clean, ROLLBACK clean).

Worker: SupplyLayerRow gains dev_group_name (L1/L2=None, L3=group); _UPSERT_SQL
adds it to INSERT/VALUES (CAST(:dev_group_name AS text)) + ON CONFLICT (key col,
not in DO UPDATE SET). Service+worker regression tests assert same-district/
different-dev_group → distinct keys (no collapse). 234 supply tests pass.

Deploy applies migration before container restart; collapsed data self-heals on
next supply_layers_refresh. Verification = prod re-measure post-deploy.

Refs #970
2026-06-04 10:06:30 +05:00
85c43ff68b fix(analyze): cad_exists_in_db must require non-NULL geometry
All checks were successful
Deploy / build-backend (push) Successful in 2m34s
Deploy / deploy (push) Successful in 2m11s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m10s
Complements the NULL-geom 500 fix. cad_exists_in_db (docstring: "is there
GEOMETRY") checked only row existence, not geom IS NOT NULL — so for the ~964
meta-but-NULL-geom parcels it returned True. Consequence after the 500 fix:
such a parcel fell into the analyze fallback, find_or_enqueue_fetch step 2 saw
cad_exists_in_db=True → returned ("ready", None) → NO NSPD fetch enqueued →
analyze looped to a 202 with job_id=null and the parcel was stuck "fetching"
forever (never pulled real geometry, never resolved).

Fix: add `AND geom IS NOT NULL` to all three EXISTS branches (aligns the
function with its docstring). Now a NULL-geom parcel → cad_exists_in_db=False →
a real NSPD fetch is enqueued (202 + real job_id) → geometry populates →
re-poll → analyze succeeds (or 404 not_in_nspd if NSPD lacks it). No more
stuck-202. Valid-geom parcels unaffected. All 3 callers want geometry-presence
semantics. 37 analyze/fetch/by-bbox tests green. Refs #944.
2026-06-03 19:59:59 +05:00
cdfa4b3ab4 fix(analyze): 500 on parcels with NULL geometry (random-parcel crash)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 3m12s
Deploy / deploy (push) Successful in 1m6s
Deploy / build-frontend (push) Has been skipped
POST /analyze 500'd (TypeError: float(None)) for ~964 cad_parcels_geom rows
that have a meta row but geom IS NULL: the resolution query returned the row
(no NULL filter), geom_wkt became NULL → ST_Centroid(NULL) → NULL lat/lon →
float(centroid_row["lat"]) crashed (the `if centroid_row else` guard checked
row existence, not NULL values). A user entering any such "random parcel" hit
a 500.

Fix:
- Add `AND geom IS NOT NULL` to all three geometry-resolution queries (initial
  UNION, inline-refetch UNION, geom_row WKT subquery) so a NULL-geometry parcel
  is treated as "no usable geometry" → falls into the #93 graceful fallback
  (enqueue NSPD on-demand fetch → 202 polling), exactly like a parcel absent
  from the DB. The fetch then populates real geometry and analyze succeeds.
- Defensive: centroid_lat/lon now check the VALUE is not None (not just row
  existence) before float(), falling back to EKB center.

Verified: badformat→400, not-in-DB→202(fetching), valid parcel→200 unaffected.
39 analyze/by-bbox tests green. Refs #944.
2026-06-03 19:50:06 +05:00
e9585bcd3b test(suite): re-align drifted tests with current code (CI-rehab 2/3)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 1m8s
Deploy / build-backend (push) Successful in 36s
Deploy / deploy (push) Successful in 1m13s
Suite was uncollectable for a while → code evolved, mocks didn't (42 failed /
5 errors → 1687 passed / 0 failed / 0 errors). TEST-ONLY, no app code changed,
no real bugs found (code-reviewed  — no test weakened to fake-green).

- best_layouts: sum_deals→deals_window rename; velocity-scaling direction for
  new SF-01 divisors (last_month=1.0/last_year=12.0)
- quarter_dump_lookup: 7→10-tuple mock rows (risks/opportunity/red_lines cols);
  fix early-exit call-counts for the cad_zouit fallback (#232)
- cadastre_bulk: add xmin/ymin/xmax/ymax to harvest-quarter db mock
- nspd_client: add required QuarterDump.opportunity
- TopLayoutRow: add required is_oversold
- analyze_{market_price,recent_permits,inline_weights}: SQL-signature dispatch
  instead of fragile positional db.execute indices (SF-B5 query reorder; also
  fixes a double-POST geom-starvation false-negative)
- custom_pois: move centroid >15km so center_bonus=0 isolates custom-POI delta
- layout/report PDF: runtime WeasyPrint probe (skip macOS dev, RUN on CI)
- mv_layout SQL: normalize +psycopg DSN + connectivity-probe skipif
- admin token tests: skip (X-Admin-Token gate removed #437/#426; protection is
  Caddy basic_auth + RBAC, covered by test_rbac)

Refs #944.
2026-06-03 19:05:43 +05:00
0247f13fb6 test(rbac): test-mode bypass so api/v1 suite can authenticate (CI-rehab 1/3)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m13s
The whole backend api/v1 test suite hits `app` via TestClient mimo Caddy, so
rbac_guard (app/main.py) 401'd every authed request (no X-Authenticated-User)
— 112 tests red, the suite effectively dead (which is how #994's district 500
shipped uncaught). Add `settings.testing` (default False — prod RBAC untouched)
+ a strictly-gated bypass at the top of rbac_guard; tests/conftest.py enables it
(env TESTING=1 + settings.testing=True before app import).

Security: bypass fires ONLY when settings.testing is True, which is set NOWHERE
in prod (default False; only tests/conftest.py flips it). RBAC's 401/403 logic
stays covered by tests/test_rbac.py (its own middleware copy, ignores the flag).

Effect: 112 → 42 failed, 1590 → 1660 passed. Remaining 42 (+5 mv_layout env
errors) are stale-mock/assertion drift + env-required tests — fixed in CI-rehab
2/3. Foundation for a real Forgejo pytest gate (3/3).

Refs #944.
2026-06-03 18:29:20 +05:00
c8ab3e8be9 fix(analyze): district KeyError 500 in #994 persist + revive by-bbox tests
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m28s
Deploy / deploy (push) Successful in 1m44s
LIVE BUG (from #994, already merged): the analysis_runs persist call-site in
analyze_parcel extracted district via result_payload["district"]["district_name"]
— but a district dict without that key (real data: partial district lookup)
raised KeyError OUTSIDE the best-effort SAVEPOINT (extraction is at the call
site, not inside persist_analysis_run) → 500 on the LIVE /analyze endpoint.
Fix: .get("district_name") → None instead of raising. Caught by reviving the
analyze test suite (below).

Test-infra (the suite was un-runnable, which is why the bug shipped):
- Lazy-import WeasyPrint in layout_tz_pdf.py + trade_in_pdf.py (matches the
  existing report_pdf.py / snapshot_pdf.py pattern). The eager top-level imports
  made `app.main` (→ parcels/trade_in routers) un-importable on hosts without
  WeasyPrint native libs (e.g. macOS dev), breaking pytest COLLECTION of the
  whole api/v1 suite. WeasyPrint is still imported when a PDF is actually rendered.
- tests/conftest.py: autouse fixture clears app.dependency_overrides after each
  test (anti-leak — a leaked get_db override caused real-DB connection attempts).
- test_parcel_by_bbox.py: rewrite get_db mocking from patch("...get_db") (a no-op
  — FastAPI Depends holds the original ref) to app.dependency_overrides[get_db],
  add explicit X-Authenticated-User header (RBAC gate), patch latest_run_dates,
  + a new test asserting last_analysis_date from a latest run (#994). 5/5 green.

NOTE: reviving collectability exposes PRE-EXISTING rot in other api/v1 suites
(analyze/admin/best_layouts: RBAC-header + stale-assertion/mock drift) — those
are NOT regressions from this PR (they were uncollectable before) and are
tracked separately for a deliberate suite-rehab + CI test-gate effort.

Refs #994.
2026-06-03 18:04:37 +05:00
72eae3bc9b feat(parcels): persist analysis runs + real last_analysis_date (#994)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m42s
Persist each successful POST /analyze into analysis_runs (migration 127):
best-effort SAVEPOINT-wrapped INSERT in a thin analysis_runs repository, then
explicit db.commit() (get_db has no commit-on-success; SAVEPOINT RELEASE alone
does not persist — without the commit the row rolls back on request teardown,
a silent no-op caught in code review). A persist failure never breaks the
response or poisons the session. Result serialized via jsonable_encoder→
json.dumps (handles embedded Pydantic models/dates); confidence/status
normalized to satisfy the 127 CHECK constraints (unknown→NULL/'complete').

Populate by-bbox last_analysis_date from v_analysis_runs_latest via a single
batch query (no N+1), replacing the #307 placeholder. The read is best-effort
wrapped too — a view-missing deploy window or future drift must not 500 the
map (falls back to last_analysis_date=None).

Additive only — analyze response shape unchanged. Tests: by-bbox suite patches
latest_run_dates (mock-db returns same rows for any query) + new test asserts
ISO last_analysis_date from a latest run. Analyze mock-suite unaffected
(graceful side_effect overflow + best-effort persist absorb the extra INSERT).
Incidental: ruff-format fixed one pre-existing f-string spacing (line ~2391)
the format hook flags on touch.

Closes #994. Refs #961.
2026-06-03 17:36:01 +05:00
0f194de069 feat(db): analysis_runs versioned store + latest-view §22 (#993)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 38s
Deploy / build-backend (push) Successful in 42s
Deploy / deploy (push) Successful in 1m0s
Migration 127: durable, versioned, re-openable persistence for Site Finder
analysis runs (the §22 API result + forecast/recommendation/report snapshot).
One row = one completed analysis of a parcel (frozen result jsonb =
SiteFinderReport.as_dict() #987, schema_version for versioned re-open).
Append-mostly (history/versioning — never overwrites prior runs → TABLE not MV).

Replaces the last_analysis_date=None placeholder (parcels.py:1026,1092):
"date of last analysis" + "current analysis of parcel" now come from
v_analysis_runs_latest (DISTINCT ON cad_num, max created_at, deterministic
id-DESC tiebreak — mirrors v_supply_layers_latest m.125).

created_by = X-Authenticated-User identity, nullable TEXT, NO FK (no users
table; precedent m.90/m.119; background/Celery runs have no author). result/
params/segment as JSONB (report shape evolves); district/confidence/status
denormalized out for filter/sort. status + confidence CHECK-guarded.
Idempotent (CREATE TABLE/INDEX IF NOT EXISTS + CREATE OR REPLACE VIEW).

Dry-run-verified vs prod catalog (DDL applies clean in rolled-back txn;
DISTINCT ON latest-wins confirmed). Foundation for #994 persist + #992
contract. Closes #993. Refs #961.
2026-06-03 17:14:47 +05:00
c06dc0f5d6 feat(db): Dom.РФ housing/escrow → macro_indicator §7.10 (#946, partial)
All checks were successful
Deploy / build-backend (push) Successful in 34s
Deploy / build-worker (push) Successful in 41s
Deploy / deploy (push) Successful in 59s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Migration 126: aggregate already-scraped domrf_kn_sale_graph + domrf_kn_objects
into monthly macro_indicator series (source=domrf): housing_price_per_m2
(area-weighted, latest-snapshot deduped — naive SUM double-counts 2.7x),
new_contracts_volume (Σ contracted ДДУ/mo), escrow_objects_count (escrow FLAG,
not balances — caveat documented). Regions sverdl (region_cd=66) + rf (all
scraped — currently = sverdl, domrf_kn covers region 66 only). Idempotent
ON CONFLICT DO UPDATE; dry-run-verified vs prod (price ~156k ₽/m², dedup
45792→16853, escrow 460). Pure SQL, data already scraped.

EMISS CPI/income (§7.11) DEFERRED: fedstat.ru WAF-blocks non-browser clients
(JA3) — needs stealth.py browser-fetch generalization (separate effort, low
priority while §9.6 forecast is advisory). Refs #946.
2026-06-03 17:05:33 +05:00
e46993dfa8 feat(#978b): Source A + detrend in rate-sensitivity backtest (#1025)
All checks were successful
Deploy / build-backend (push) Successful in 28s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 1m2s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
2026-06-03 09:51:48 +00:00
6f06ca7f16 feat(forecasting): read-only OOS backtest of §9.6 rate-sensitivity (#978) (#1024)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 41s
Deploy / build-worker (push) Successful in 44s
Deploy / deploy (push) Successful in 1m0s
2026-06-03 09:33:02 +00:00
12162a0abb feat(exporters): Site Finder report PDF exporter (#989, 955-A3) (#1023)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m23s
Deploy / build-backend (push) Successful in 3m30s
Deploy / deploy (push) Successful in 1m7s
2026-06-03 09:09:30 +00:00
299f2365fb feat(exporters): Excel exporter for SiteFinderReport (#991, 955-A5) (#1022)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m24s
Deploy / deploy (push) Successful in 1m24s
Deploy / build-worker (push) Successful in 2m40s
2026-06-03 08:57:00 +00:00
fc312fe606 feat(forecasting): §13 report assembler (#988, 955-A2) (#1021)
All checks were successful
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m36s
Deploy / deploy (push) Successful in 1m13s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
2026-06-03 08:51:23 +00:00
47fe5a55eb feat(forecasting): §15 confidence engine v2 (#990, 955-A4) (#1020)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m24s
Deploy / build-worker (push) Successful in 2m58s
Deploy / deploy (push) Successful in 1m16s
2026-06-03 08:41:07 +00:00