Commit graph

69 commits

Author SHA1 Message Date
2a68c2e202 fix(parcels): guard NULL geom + None lat/lon в isochrones/poi-score (#1201)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
`cad_parcels_geom` — VIEW над `cad_parcels` где `geom` nullable (~964 prod-
строк с NULL geom задокументировано в analyze:1504-1507). Без фильтра
`geom IS NOT NULL` в UNION-ветках:
- NULL-строка из ранней ветки (`cad_quarters_geom`) ПЕРЕКРЫВАЕТ валидную
  геометрию из поздней (`cad_parcels_geom`) — LIMIT 1 берёт первую попавшуюся.
- ST_Centroid(NULL) → NULL → coord_row.lat = NULL → float(None) → TypeError
  → 500 (вместо честного 404).

Guard `if not coord_row` НЕ ловил эту дыру: RowMapping с None-полями
truthy. Зеркало паттерна из analyze (parcels.py:1418-1438) для обоих
эндпоинтов (`/isochrones` + `/poi-score`):
1. `AND geom IS NOT NULL` во ВСЕХ ветках UNION.
2. Belt-and-suspenders: явная проверка `coord_row.get("lat") is None`
   на случай топологически битого polygon'а (NOT-NULL geom, NULL centroid).

26 analyze/isochrones/poi-score юнит-тестов зелёные. ruff clean.

Closes #1201
2026-06-13 05:24:25 +00:00
c8000d1089 test(parcels): integration EXPLAIN-gate для analyze hot-path SQL (#1198)
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m24s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m46s
Deploy / build-worker (push) Successful in 2m55s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m10s
PR #1195 ввёл CTE alias `overlaps` (PG keyword для time-period operator)
→ каждый POST /analyze падал с syntax error ~10ч до hotfix PR #1197. Mock-БД
в tests/api/v1/test_*.py не поймала: MagicMock на db.execute() не парсит SQL.

Расширяет существующий phantom-column gate (tests/integration/) на
_neighbors_summary и parcel_ird_overlaps SQL:

1. _NEIGHBORS_SUMMARY_SQL вынесен из inline-text в module-level constant
   в parcels.py (паттерн как в best_layouts/ird_overlay_lookup/krt_lookup).
   Заодно сконвертирован на canonical CAST(:wkt AS text) — backend.md rule.
2. EXPLAIN-тесты через phantom_check_session (SSH-туннель к prod-PG) —
   skip без TEST_DATABASE_URL, run в спец CI job.
3. Compile-time guards (всегда работают, без БД):
   - CTE-alias не должен быть PG-keyword (overlaps/user/current_date/select/where)
   - SQL не должен содержать :bind::type (psycopg v3 antipattern)

Поймал бы:
- #1195 (CTE alias overlaps) — оба EXPLAIN-тест + compile-time keyword guard
- :type recurring class — compile-time parametrized guard
- Phantom column / typo PostGIS function — EXPLAIN parses + plans

Refactor backward-compatible: _neighbors_summary использует ту же константу,
behavior идентичен. 26 unit-тестов parcels analyze продолжают зелёные.

Refs #1198
2026-06-13 09:43:49 +05:00
2b23a4b24b fix(parcels): rename CTE overlaps → overlap_rows (#1196 regression of #1195)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m12s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m25s
Deploy / build-backend (push) Successful in 1m45s
`OVERLAPS` — это PostgreSQL keyword (binary operator для time-periods —
`(start1,end1) OVERLAPS (start2,end2)`). Парсер ругается на `overlaps AS (...)`
в WITH-блоке с

  ERROR: syntax error at or near "overlaps"

→ exception → транзакция в aborted state → весь analyze пайплайн ниже
получает InFailedSqlTransaction (ird_overlays, persist, ird block) → фронт
видит data_available=False, neighbors=[]. Воспроизводится на КАЖДОМ
/analyze с момента мерджа PR #1195.

Mock-БД в pytest tests/api/v1/ синтаксис не проверяет — отсюда регрессия
прошла CI. Прямой psql-replay на проде:
  WITH neighbors AS (...), overlaps AS (...) → syntax error
  WITH neighbors AS (...), overlap_rows AS (...) → ok

Минимальная переименование CTE alias (4 строки: 2 в SQL + 1 в SELECT
column alias + 1 в Python `row["overlap_rows"]`). Семантика идентична PR #1195.

Memory: future-proofing — добавить интеграционный тест с реальной PG для
analyze hot-path (отдельный issue, не блокер этого hotfix).

Refs #1196
2026-06-13 08:59:45 +05:00
49b85ab1d6 perf(site-finder): air-quality TTL-кэш + neighbors_summary 2→1 SQL (#1130 Phase B)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m24s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
CI / changes (push) Successful in 6s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m24s
Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194):

1. get_air_quality_cached в weather_cache (третий TTL-слот рядом с forecast/climate):
   - hit-TTL 1h (hourly bucket Open-Meteo), negative-TTL 5min.
   - timeout 5s → 2s.
   - single-flight под per-cache threading.Lock.
   - Снимает 0.26с на cache-hit и до 5с при DNS-fail (тот же антипаттерн,
     что был у forecast/climate до Phase A).

2. _neighbors_summary: 2 SQL → 1 statement.
   - WITH neighbors AS (... LIMIT 30), overlaps AS (... LIMIT 5)
   - + COALESCE(json_agg(row_to_json(...)), '[]'::json) в основном SELECT.
   - Один round-trip вместо двух (~-47 ms на каждый analyze).
   - Семантика идентична: те же ST_DWithin(geom, point, 100m) для neighbors,
     ST_Intersects + ST_Area для overlaps. Anti-fakes guards (_COST_PER_M2_MIN/MAX,
     float() try/except, >50 m² overlap) сохранены.

Формат возвращаемых dict не меняется (frontend контракт). 5 новых юнит-тестов в
test_weather_cache.py (TestAirQualityCache: hot-hit, negative, empty-hourly,
separate-from-weather, ttl-expires). 7 файлов API-тестов обновили patch-цель
_fetch_air_quality_sync → get_air_quality_cached.

Refs #1130
2026-06-12 22:42:32 +05:00
97ff0c9e81 perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m26s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-worker (push) Successful in 4m51s
Deploy / deploy (push) Successful in 1m24s
Два внешних HTTP-вызова в analyze (_fetch_weather_sync + _fetch_seasonal_weather_sync)
сидели без кэша. На проде в private network с restricted egress DNS до *.open-meteo.com
фейлит и каждый replay висит полный timeout (5s + 15s = до 20с лишних на analyze).
Профиль cProfile одного replay: 2.38с в httpx + 1.73с в SSL recv.

Вынес логику в app/services/weather_cache.py:
- TTL hit: forecast 6h, climate normals 7d. Negative-cache: 5min (DNS-fail не повторяет
  timeout на каждый analyze, восстановление подхватывается через ~5 минут).
- Ключ — округлённые до 0.01° (~1 км) координаты.
- Single-flight: per-cache threading.Lock + check-then-fetch-then-store.
- Тайм-ауты сокращены: forecast 5s→2s, climate 15s→3s.

Контракт для caller'а не изменился: None по-прежнему допустим (env_ok-флаг в
_build_environmental_score). 7 файлов API-тестов обновили patch-цели на новые имена
get_weather_cached / get_seasonal_weather_cached. 11 новых юнит-тестов в
tests/services/test_weather_cache.py (single-flight через threading.Barrier(16), TTL
expire через monkeypatch _now, изоляция forecast vs climate).

Refs #1130
2026-06-12 19:58:07 +05:00
d9b92f19f4 feat(sf): вшить parcel_reservations в ИРД-блок analyze (#1127)
All checks were successful
Deploy / build-worker (push) Successful in 2m36s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / deploy (push) Successful in 1m17s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 11:09:26 +00:00
4cc0b6da8c feat(sf): включить enable_ird_analyze + latency-hardening (#1115)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m12s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 10:00:48 +00:00
40b1f3ad69 feat(export): add PPTX presentation export of §22 forecast report (#959)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m58s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m26s
CI / backend-tests (pull_request) Successful in 6m7s
Completes EPIC #959 export formats (md/json/tg/docx/pptx; PDF/XLSX pre-existing).
render_report_pptx (python-pptx, pure + graceful, reuses report_pdf helpers,
local import) → 6-slide deck. Endpoint format=pptx (early-return, presentationml
media-type, .pptx attachment). Add python-pptx + regenerate uv.lock (python-pptx
+ xlsxwriter; uv sync --frozen passes).

Closes #959.
2026-06-07 14:09:35 +05:00
3a89287c6b feat(export): add DOCX export of §22 forecast report (#959)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m49s
Deploy / build-worker (push) Successful in 3m27s
Deploy / deploy (push) Successful in 1m13s
CI / backend-tests (pull_request) Successful in 6m4s
render_report_docx (python-docx) mirrors report_md/report_pdf section order &
content, reuses report_pdf pure helpers (DRY), graceful on thin/empty report.
Widen /forecast/export format Literal to include docx → Word attachment.
Add python-docx dep + regenerate uv.lock (uv sync --frozen passes). Part of #959.
2026-06-07 13:58:04 +05:00
7b40e7e480 feat(sf): wire ИРД-слой в analyze за флагом enable_ird_analyze (#1101)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m33s
Deploy / build-worker (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m11s
D9b capstone — поле `ird` в ответе analyze за флагом enable_ird_analyze (default OFF): parcel_ird_overlaps (м.132, incl opportunity) + функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент (C8b cache-first). Логика в self-contained ird_analyze.py; parcels.py +13 строк flag-gated, defense-in-depth ×3 graceful. Schema additive (ird Optional). Замыкает ИРД-эпик #1067 (#1078/#1090/#1092/#1099/#1100/#1058/#1060). 4 теста.

Refs #1067.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 19:50:57 +00:00
579ad54202 feat(export): compact Telegram summary export for §22 forecast (#959)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
render_report_telegram_summary (pure, no new deps, DRY-reuses report_pdf str
helpers) + `tg` format on GET /{cad}/forecast/export → inline text/plain snippet
(no attachment, copy-paste-ready). md/json unchanged; no-run 404, bad format 422.
Graceful on thin/empty reports. Part of EPIC #959.
2026-06-06 18:55:47 +00:00
25f7052084 feat(export): Markdown + JSON download of §22 forecast report (#959)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m34s
Deploy / deploy (push) Successful in 1m12s
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
render_report_markdown (pure, no new runtime deps) reuses report_pdf's str
helpers (DRY), + GET /{cad_num}/forecast/export?format=md|json. No forecast run
→ 404; graceful on thin/empty reports; GFM-safe table escaping. PDF/XLSX already
existed; this adds the cheapest no-dep formats. Part of EPIC #959.
2026-06-06 18:39:51 +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
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
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
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
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
c363ea133f fix(api): /parcels/by-bbox 500 — area via ST_Area, drop missing land_category (#347)
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 / build-worker (push) Successful in 2m29s
Deploy / deploy (push) Successful in 47s
2026-05-17 23:09:18 +00:00
c61c8c86af feat(sf-b1): GET /parcels/by-bbox — map entry endpoint + parcel_user_status table (#336)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
2026-05-17 21:25:48 +00:00
b013fd886c feat(sf-b6): GET /parcels/{cad}/poi-score — weighted top-7 (#333)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m35s
Deploy / build-worker (push) Has been cancelled
2026-05-17 21:11:25 +00:00
493238447a feat(sf-b5): extend /parcels/{cad}/analyze with 15 fields (EGRN/encumbrance/red_lines/risks/district prices) (#332)
Some checks failed
Deploy / changes (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
2026-05-17 21:11:18 +00:00
4040cba4c0 feat(sf-b7): GET /parcels/{cad}/snapshot.pdf — 1-page WeasyPrint export (#334)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
2026-05-17 21:11:03 +00:00
e872b1ebe4 fix(sf): exclude NULL competitors from aggregate calculations (velocity, market_avg, top_sellers) (#328)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m26s
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 48s
2026-05-17 20:46:07 +00:00
7a8d59beb4 feat(obj-3): SF backend migrate to objective_lots ground truth (price 0.3% -> 81%) (#324)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
2026-05-17 20:08:17 +00:00
lekss361
e1a8ad9395 fix(sf-17): rosreestr velocity fallback via cad_quarter (~100% EKB coverage)
When Objective mapping coverage falls below 50% of competitors in the radius,
fall back to rosreestr_deals JOIN on the parcel's cadastral quarter. Audit shows
237/237 EKB quarters (100%) have rosreestr data for the last 12 months, compared
to <20% Objective coverage before bulk mapping.

- velocity.py: add _compute_rosreestr_fallback(), _OBJECTIVE_COVERAGE_MIN_RATIO
  constant, velocity_source field on VelocityResult (objective/rosreestr_fallback/none)
- parcels.py: extract cad_quarter from cad_num, pass to compute_velocity
- site-finder.ts: add velocity_source field to Velocity interface
- VelocityBlock.tsx: badge "Источник: квартальные сделки" when rosreestr_fallback

Epic #271 item #17
2026-05-17 16:50:19 +03:00
lekss361
989d4e5b7d fix(sf-20): adaptive district threshold 30→15 with data_confidence flag
- v_bucket_success_score HAVING >= 30 → >= 15 so sparse districts
  (Кировский etc.) are no longer silently excluded from the block
- Backend: SUCCESS_REC_MIN_DEALS=15, SUCCESS_REC_STRONG_DEALS=30;
  data_confidence='weak' (15-29 deals) or 'strong' (≥30) in response
- TS type ParcelSuccessRecommendation: add data_confidence literal
- SuccessRecommendationBlock: amber badge for weak, all hex → CSS tokens,
  emoji star removed (ui-ux.md), empty-state text min 30→15
2026-05-17 16:34:52 +03:00
lekss361
4d39ae5b5d fix(sf-07): use median_12m from mv_quarter_price_per_m2 for district price
ekb_districts.median_price_per_m2 was populated from a 24-month window.
LEFT JOIN mv_quarter_price_per_m2 on the cad quarter and COALESCE(median_12m,
fallback) so the API returns the 12-month median where available.
Frontend OverviewTab adds a "(12 мес)" hint next to the value.

Closes #271 item 7
2026-05-17 14:28:32 +03:00
lekss361
1799e1fb30 fix(sf-02): добавить site_status/ready_dt в /analyze competitors + sort строящиеся first
SQL в /analyze не выбирал site_status, ready_dt из domrf_kn_objects;
ORDER BY flat_count DESC ставил сданные ЖК в топ.

Closes (epic part) #271 item 2
2026-05-17 13:29:59 +03:00
lekss361
485f489bd2 feat(#254): user_custom_pois — backend schema + CRUD + scoring integration
- SQL migration 101_user_custom_pois.sql: table with GEOGRAPHY(POINT) + GIST index
- Pydantic schemas: CustomPoiCreate / CustomPoiUpdate / CustomPoiOut
- Service custom_pois.py: CRUD + get_overlaps_for_scoring (1km radius, psycopg v3 CAST)
- API /api/v1/custom-pois: POST(201)/GET/PATCH/DELETE, X-Session-Id header auth
- Scoring in analyze_parcel: custom POI block after OSM POI loop,
  decay weight * max(0, 1 - dist/1000), custom_poi_score_items in response
- Tests: CRUD + validation (weight/lon/lat 422) + scoring integration (mock-based)
- Vault: Schema_User_Custom_Pois.md + Endpoint_Parcel_Analyze v3.9 section
2026-05-17 09:34:33 +03:00
lekss361
f70be684da feat(#29,#232): wire cad_parcels.permitted_use in analyze + cad_zouit fallback (G2+G3)
TASK A (#29 G2): add parcel_meta to analyze response
- New ParcelMeta Pydantic schema in app/schemas/parcel.py
- SELECT from cad_parcels WHERE cad_num=:c in analyze_parcel() (step 9f)
- Returns permitted_use_established_by_document, land_record_category_type,
  land_record_subtype, cost_value; None when row absent
- Tests: test_analyze_parcel_meta.py (found + not-found cases)

TASK B (#232 G3): cad_zouit fallback in _get_zouit_overlaps
- When nspd_quarter_dumps has zouit_count==0, fall back to ST_Intersects
  query on cad_zouit (3483 rows, GIST indexed)
- Overlaps tagged with source='cad_zouit'; format compatible with NSPD path
- gate_verdict.py: BLOCKER_TYPE_ZONE_KEYWORDS tuple for keyword-based
  classification (охранная зона / трубопровод / электр / газ -> blocker;
  СЗЗ -> warning); NSPD subcategory path preserved backward-compat
- Tests: 6 new test cases in test_gate_verdict.py covering cad_zouit path
  and backward-compat for NSPD subcategory path

Updated db.execute call sequence in test_analyze_*.py (index shift +1 at pos 10).
2026-05-17 08:17:22 +03:00
lekss361
ed3c128528 feat(nspd): TIER 4 opportunity layers + red lines (#94 PR2 of 4)
- NSPDClient: QUARTER_OPPORTUNITY_LAYERS (auction/scheme/free/future/oopt)
  + QuarterDump.opportunity field
- nspd_sync: harvest_quarter accepts include_opportunity, denorm cols
  has_auction_parcels + opportunity_count in UPSERT
- quarter_dump_lookup:
  - _get_opportunity_parcels (sort by distance, early-exit on count=0)
  - _get_red_lines (query existing dump.red_lines core path, layer='red_lines')
- SQL 89_*: has_auction_parcels + opportunity_count + partial index
- Pydantic: OpportunityParcel + RedLine schemas
- /analyze: nspd_opportunity_parcels + nspd_red_lines fields
- Frontend: NspdOpportunityBlock + NspdRedLinesBlock + LandTab integration
- Tests: 28 total (11 PR1 + 17 PR2), all pass

Red lines uses existing core harvest path (layer 879243 already in dump.red_lines
from PR1+core) — no duplicate harvest, no red_lines_count_v2 column needed.

Part of #94
2026-05-16 19:06:22 +03:00
lekss361
1595157ebf feat(nspd): enable TIER 3 risk zones harvest + extract in analyze (#94 PR1)
- harvest_stale_quarters fanout passes include_risks=True to all queued tasks
- _get_risk_zones() with ST_Intersects + ST_Area intersection calc
- _extract_features_by_layer() generic helper for future PR2+ reuse
- RiskZone Pydantic schema; /analyze returns nspd_risk_zones list
- NspdRiskZonesBlock component with severity color-coding (red/yellow/orange)
- 11 unit tests for extract/intersect/null-area/db-exception paths

Part of #94 (TIER 3 risk layers — flooding, landslide, burns, erosion etc)
2026-05-16 16:11:59 +03:00
46203ab365 feat(permits): Celery beat monthly + analyze recent_permits (#105 Phase 4+5) (#213)
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Failing after 16s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m44s
Deploy / deploy (push) Has been skipped
2026-05-16 12:23:52 +00:00
27a0957bb5 feat(analyze): add market_price из mv_quarter_price_per_m2 (#33 PR B) (#210)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m27s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m43s
Deploy / deploy (push) Successful in 40s
2026-05-16 11:28:16 +00:00
092976f656 feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1) (#206)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m44s
Deploy / build-frontend (push) Successful in 2m9s
Deploy / deploy (push) Successful in 37s
2026-05-16 11:00:41 +00:00
256909d28b feat(layouts): PDF ТЗ endpoint + BestLayoutsBlock UI (#113 PR D) (#199)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 3m3s
Deploy / build-frontend (push) Successful in 2m8s
Deploy / deploy (push) Successful in 38s
2026-05-16 09:53:05 +00:00
ad16fc0e42 feat(parcels): best-layouts endpoint + service (#113 PR C) (#196)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m44s
Deploy / deploy (push) Successful in 37s
2026-05-16 09:10:06 +00:00
4deb1b0284 feat(parcels): connection-points endpoint (Forgejo #115 Phase 1 backend) (#190)
Some checks failed
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Failing after 0s
Deploy / build-worker (push) Successful in 5m11s
Deploy / deploy (push) Failing after 0s
Deploy / build-backend (push) Successful in 3m44s
2026-05-16 05:58:27 +00:00
e561df1b55 feat(parcels): competitors endpoint (Forgejo #112 Phase 1 backend) (#191)
Some checks failed
CI / backend (push) Successful in 1m16s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Failing after 12s
Deploy / build-worker (push) Failing after 12s
Deploy / build-frontend (push) Failing after 0s
Deploy / deploy (push) Failing after 0s
CI / frontend (push) Successful in 2m17s
2026-05-15 22:21:05 +00:00
lekss361
608490fbba
fix(parcels): _parse_floors handle int (post-migration #169) (#176)
* fix(parcels): _parse_floors handle int (post-migration #169 schema change)

After PR #169 cad_buildings schema migration, `floors` column is INT
(was TEXT in legacy schema). Existing `_parse_floors(r.get("floors"))`
call in analyze_parcel → _neighbors_summary crashes with:
  AttributeError: 'int' object has no attribute 'strip'

Fix: type union str | int | None. If int → return directly (no strip).
Preserve TEXT range parsing ("5-7" → 7) for backwards-compat with
any legacy data still in cad_buildings_old_apr26.

* test(smoke): production smoke tests for post-deploy regressions (#168)

Add tests/smoke/test_prod_smoke.py covering known regression surfaces.
Marks: prod_smoke + slow. Env: PROD_SMOKE_BASE_URL, PROD_SMOKE_ADMIN_TOKEN.
Run manually: cd backend && uv run pytest tests/smoke/ -m prod_smoke -v

* fix(cadastre): exact-match headers to nspd_lite to bypass NSPD WAF (#168)

Pilot v2 (job_id=2) failed 50/50 with HTTP 403 WAF block. Comparing
nspd_bulk_client.DEFAULT_HEADERS vs legacy nspd_lite.HEADERS (which works
on VPS IP since April 2026):

  PascalCase keys → lowercase keys
  Chrome/148 UA → Chrome/144 UA
  No cache-control / pragma → "no-cache" both
  accept-language ru first → en first
  No origin → "https://nspd.gov.ru"
  referer "/map" → "/map?thematic=PKK"

NSPD WAF (BotShield-class) likely fingerprints на header order + values
combined with TLS fingerprint. Matching legacy exactly minimizes deltas.

Test plan: retry pilot job after deploy, expect 0 WAF blocks for first
5 quarters.

* fix(test): exclude prod_smoke tests by default (#168)

CI ran tests/smoke/test_prod_smoke.py and they hit production
https://gendsgn.ru/api/v1/parcels/.../analyze which currently returns 500
(parse_floors regression — exactly what this PR fixes). Catch-22: PR can't
merge because smoke tests fail against pre-merge prod.

Fix: add `addopts = ["-m", "not prod_smoke"]` so default pytest excludes
them. Run manually post-deploy with: pytest -m prod_smoke -v

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 15:08:16 +03:00
lekss361
30d78c5030
fix(pipeline): enrich obj_class via objective_lots COALESCE fallback (#165)
domrf_kn_objects.obj_class всегда NULL (Bug_Kn_API_Obj_Class_Always_Null_OPEN).
Pipeline 24mo UI показывал 'Не указан: 4075 квартир'.

Fix: SQL COALESCE с correlated subquery через objective_complex_mapping →
objective_lots.class. Mapped complexes (114/4548) теперь резолвят корректный
класс — комфорт/стандарт/бизнес/премиум.

## Coverage impact (verified via MCP)

Pre-fix: 0 of 1316 planned ЖК имели obj_class
Post-fix: 339 of 1316 (25.8%) — 249 комфорт, 45 стандарт, 42 бизнес

Не-mapped objects остаются 'Не указан' (upstream kn-scraper fix всё ещё open).

Closes #162

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 10:07:52 +03:00
lekss361
c39b53cf53
fix(site-finder): cascade InFailedSqlTransaction — velocity internal rollback (#155)
* fix(site-finder): db.rollback() в velocity catches + SAVEPOINT zoning/success_rec

velocity.py 3 internal try/except возвращали None БЕЗ db.rollback(). PR #154
SAVEPOINT не помог — exception не propagates из velocity. Postgres tx
aborted внутри, cascade InFailedSqlTransaction на следующих queries.

Fix:
- velocity.py: db.rollback() в 3 catch блоках
- parcels.py: SAVEPOINT обёртки для zoning + success_recommendation

Refs: user reports 2026-05-15 cascade 500

* fix(velocity): SAVEPOINT per query (not db.rollback) — correct SQLAlchemy 2.0 pattern

Per PR #155 bot review — мой db.rollback() в catches был НЕПРАВИЛЬНЫЙ.

Session.rollback() = ENTIRE outer tx (не savepoint). Outer SAVEPOINT context
становится orphaned → InvalidRequestError на __exit__.

Correct fix: wrap каждый db.execute в свой with db.begin_nested():
- Failure → __exit__ propagates exception → SAVEPOINT rolls back
- Outer tx остаётся clean
- velocity returns None gracefully → caller продолжает

Все 3 SQL queries в velocity.py теперь SAVEPOINT-wrapped:
- compute_velocity: competitor_query
- compute_velocity: sales_rows query
- _get_ekb_median: median query

db.rollback() removed из всех 3 catches.

Refs: PR #155 bot review

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 08:52:38 +03:00
lekss361
b13c4be0ed
fix(parcels): SAVEPOINT around velocity compute → не abort outer tx (#154)
Production POST /parcels/{cad}/analyze падает с 500:
  sqlalchemy.exc.InternalError: (psycopg.errors.InFailedSqlTransaction)
  current transaction is aborted, commands ignored until end of transaction block

## Root cause

`compute_velocity()` SQL fails (probably на cad's без conkurrentов / sparse
sale_graph data) → exception caught в try/except → НО db.rollback() отсутствует
→ transaction остаётся в aborted state.

Следующая query (_geotech_risk на line 828) пытается выполниться → крашится
с InFailedSqlTransaction.

## Fix

Wrap velocity в `with db.begin_nested()` — SAVEPOINT pattern (consistent
with PR #124 pzz_loader fix). Failure внутри savepoint:
- Rollbacks ТОЛЬКО savepoint
- Outer transaction остаётся clean
- Subsequent queries (_geotech_risk и пр.) работают

Pattern matches feedback_subagent_delegation audit recommendation для
in-loop / per-section exception handling.

## Impact

POST /parcels/{cad}/analyze больше не 500 при velocity failure. Возвращает
`velocity: null` + остальные fields normal.

Refs: user report 2026-05-15 InFailedSqlTransaction

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 08:29:24 +03:00
lekss361
e93bb6121e
feat(site-finder): D2 velocity-score from domrf_kn_sale_graph (#34 sub-PR 1/2) (#146)
compute_velocity service queries competitor sales в радиусе 3км:
- ST_MakePoint(longitude, latitude) — domrf_kn_objects не имеет geom column
- JOIN domrf_kn_sale_graph за 6 мес (area_sq primary, realised*45 fallback)
- Normalize vs ЕКБ-wide median → velocity_score 0..1
- confidence: high/medium/low (competitors_count + months_observed)
- Top 5 sample competitors для UI

Integration: analyze_parcel.response['velocity'] top-level field.

Schema corrections vs spec:
- obj_name → comm_name
- region_code → region_cd
- contracted (INT) → area_sq (м²)

Tests: 102/102 pass.
Vault: Module_Velocity_Service.md NEW.

Closes #144 (sub-PR 2 frontend закроет #34)

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 01:24:53 +03:00
lekss361
76a88b2827
feat(site-finder): gate verdict aggregator — can-build-MKD signal (#32 G5 sub-PR 1/2) (#142)
Pure-function aggregator collapses nspd_zoning / nspd_zouit_overlaps /
nspd_engineering_nearby / nspd_dump в один GateVerdict TypedDict.

Logic:
- ПЗЗ не Ж-* → BLOCKER
- ЗОУИТ sub=17 (инжен. охранная) → BLOCKER
- Другие ЗОУИТ → WARNING
- Нет инжен. сетей в 200м → WARNING
- nspd_dump stale → source: nspd_dump_partial
- Нет dump → can_build_mkd: 'unknown'

Integration: новое поле gate_verdict в analyze_parcel response.

Tests: 15/15 pass (mock-based).

Vault: code/modules/Module_Gate_Verdict.md NEW.

Closes #140 (sub-PR 2 frontend закроет #32)

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 00:59:53 +03:00
lekss361
7250aa6187
feat(site-finder): weight profiles endpoints + analyze integration (#114 sub-PR 3/4) (#138)
Admin CRUD router + non-breaking analyze_parcel integration. 46 tests pass.

Refs: #114

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 00:40:06 +03:00
lekss361
1c2f80a4b8
feat(site-finder): integrate nspd_quarter_dumps cache в analyze_parcel (#94 Sprint 1.1 FINAL) (#116)
* feat(site-finder): integrate nspd_quarter_dumps cache в analyze_parcel (#94 Sprint 1.1 #4 FINAL)

Замыкает Sprint 1.1 из #94 part 2 plan. После этого PR пользователь видит
свежие НСПД данные в UI (frontend integration — отдельный PR).

Backend (new app/services/site_finder/quarter_dump_lookup.py):
- `derive_quarter_cad(cad_num)` — 3/4/5-сегмент → quarter (3-segment)
- `get_quarter_dump_data(db, cad_num, parcel_wkt)` — main entrypoint:
  - Reads nspd_quarter_dumps row для derived quarter
  - Freshness threshold: 180 days
  - Missing/stale/harvest_error → trigger harvest_quarter.apply_async() fire-
    and-forget (lazy import против circular), return EMPTY_DUMP_RESULT
  - Fresh + parcel_wkt=None → metadata only (no spatial queries)
  - Fresh + geometry → 3 spatial queries via jsonb_array_elements + ST_Transform
    (3857→4326) + ST_Intersects / ST_DWithin
- 3 private helpers:
  - `_get_zoning` — point-in-polygon parcel centroid vs territorial_zones, LIMIT 1
  - `_get_zouit_overlaps` — все zouit_% layers пересекающиеся с parcel
  - `_get_engineering_nearby` — engineering_structures в 200m, sorted by distance
- `EMPTY_DUMP_RESULT` module-level constant — DRY для no-dump fallback (used
  in get_quarter_dump_data internal + analyze_parcel try/except wrap)

Backend (parcels.py):
- Import EMPTY_DUMP_RESULT + get_quarter_dump_data
- Call wrapped в try/except — если nspd_quarter_dumps недоступна (DB timeout
  / table missing) → EMPTY_DUMP_RESULT fallback вместо 500 (consistent с
  resilience pattern других optional fetches)
- Response gets 4 new fields:
  - nspd_zoning: dict | None (G1 ПЗЗ — zone_code, zone_name, source)
  - nspd_zouit_overlaps: list[dict] (G3 — overlaps в parcel, per ЗОУИТ group)
  - nspd_engineering_nearby: list[dict] (I3 — engineering structures в 200m)
  - nspd_dump: dict (freshness metadata — available, fetched_at_utc, stale,
    harvest_triggered, total_features)

Tests: 13 new в test_quarter_dump_lookup.py (mock-based, no real DB):
- derive_quarter_cad 5 edge cases (3seg, 4seg, 5seg, invalid, whitespace)
- get_quarter_dump no_row → harvest triggered
- stale (>180d) → harvest triggered, stale=True
- harvest_error row → retry harvest triggered
- parcel_wkt=None → metadata only (1 DB call)
- fresh + zoning extraction
- fresh + zouit_overlaps list
- fresh integration: все 4 keys present

47 pre-existing tests still pass.

Code review (code-reviewer pre-push): MINOR, 0 blocking. Applied 2 of 4:
-  #1: try/except wrap around get_quarter_dump_data в analyze_parcel
  (защита от DB unavailability) + DRY через EMPTY_DUMP_RESULT module const
-  #2: removed redundant nspd_zoning.fetched_at_utc (DRY — freshness в
  nspd_dump.fetched_at_utc)
- ⏭ Deferred (acceptable): #3 ad-hoc harvest_quarter retry cooldown для
  harvest_error rows (только при high traffic + persistent NSPD errors);
  #4 raw_props в response — tech debt, убрать вместе с frontend PR

Performance note: 3 spatial queries per analyze adds ~10-50ms on typical
~100-feature quarter. Mitigation if quarters grow dense: materialized
per-layer sub-table (отдельная DB issue).

Closes Sprint 1.1 part of #94. Frontend rendering этих 4 полей — отдельный
PR (next: #112 / #115 / #114).

* fix(site-finder): address PR #116 auto-review M1-M5

M1 (mutation risk): replace EMPTY_DUMP_RESULT direct refs with
_make_empty_result() factory. dict(...) shallow copy left nested
nspd_dump shared by reference across concurrent requests — single
mutation pollutes module sentinel for all subsequent calls. Now
каждый caller gets independent dict.

M2 (O(N) spatial scan): SELECT extended denormalized counts
(territorial_zones_count, zouit_count, engineering_count). Each
spatial helper accepts layer_counts and early-returns when count=0
— skips heavy jsonb_array_elements + ST_Transform/ST_Intersects
scan entirely. Critical для quarters с 2000+ features.

M4 (documentation): _trigger_harvest docstring describes known
burst/no-dedup limitation + TODO Redis SETNX (отдельный PR).

M5 (test fragility): _make_db_mock_with_spatial docstring describes
positional-call contract — db.execute order (0=dump, 1=zoning, 2=zouit,
3=engineering) и зависимость от count-values.

+4 new tests (17 total, all pass):
- test_make_empty_result_returns_independent_copies (mutation safety)
- test_make_empty_result_overrides
- test_early_exit_all_counts_zero_no_spatial_queries
- test_early_exit_partial_counts

Per auto-review on 3068a9c.

* fix(site-finder): rename _make_empty_result → make_empty_result (public) per PR #116 review

M1 residual fix: parcels.py exception path использовал EMPTY_DUMP_RESULT
singleton ref вместо factory. Сейчас readonly access, но нарушает
documented invariant модуля.

Rename `_make_empty_result` → `make_empty_result` (public API), import в
parcels.py, использовать в try/except fallback. Каждый request получает
независимый dict — никаких shared references.

M4 (Redis SETNX dedup) + M5 (test fragility) — deferred per review,
documented в code/issue. Acceptable trade-offs:
- M4: UPSERT idempotency делает данные safe; burst-duplicate task'и тратят
  WAF traffic впустую но не повреждают данные.
- M5: docstring contractually describes positional-call order.

17/17 tests pass. ruff/format clean.

Per auto-review on aef8308.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-13 09:14:19 +03:00
lekss361
bc092b58f1
feat(scrapers): NSPD client foundation (#94) — search + WMS + layers (#98)
* feat(scrapers): NSPD client foundation (#94) — search_by_cad + WMS + layers

Foundation для G1 #28 ПЗЗ, G2 #29 ВРИ, G3 #30 ЗОУИТ, E1 #51 cad_parcels,
I3 #44 engineering, и поддержки on-demand #93.

Backend (`app/services/scrapers/nspd_client.py`):
- `NSPDClient` с 4 core методами:
  - `search_by_cad(cad, thematic_id)` — REST /api/geoportal/v2/search
    возвращает GeoJSON + ВРИ + land_category + cost_value за 1 запрос
  - `get_feature_info(layer_id, lon, lat, buffer_m=100)` — WMS GetFeatureInfo
    на точке: какие feature'ы layer'а покрывают (lon,lat)
  - `get_features_in_bbox(layer_id, bbox_3857)` — bulk через WMS workaround
    (WFS GetCapabilities → 404, поэтому большой bbox + центральная I/J точка)
  - `list_layers(theme_id)` — каталог слоёв в теме (PKK=1, ARN=665)
- Typed responses: NSPDFeature, NSPDSearchResult, NSPDLayer (frozen dataclasses)
- LAYERS catalog: 32 layer-id с семантическими именами (territorial_zones=875838,
  zouit_engineering=37578, risk_flooding=872205, etc) — TIER 1-6 per #94
- Coordinate helpers: lonlat_to_3857(), bbox_around_point_m()
- Reuse: HEADERS + SSL ctx + fetch_geoportal из existing nspd_lite (WAF-compatible
  urllib trick). Rate limit через rate_ms.
- WAF/Rate-limit: raises NspdLiteWafError на 403/429 — caller backoff.

Tests (`backend/tests/test_nspd_client.py`): 14 unit tests, no network.
- LAYERS catalog sanity (territorial_zones=875838 закрывает G1)
- Coordinate transforms (zero, ЕКБ center, bbox)
- NSPDFeature.from_raw parsing (full / missing fields)
- NSPDSearchResult helpers (empty/first)
- _walk_layer_tree (flat / nested / empty)
- search_by_cad через monkeypatch fetch_geoportal

Также включён micro-fix из PR #95 review (cosmetic):
- parcels.py inline comment "до 25s" → "до 15s" (matched _INLINE_FETCH_WAIT_S)

Scope NOT в этом PR (отдельные follow-up issues, sequential rule):
- Schema migrations (cad_parcels_geom +columns, nspd_territorial_zones,
  nspd_zouit, nspd_red_lines, nspd_risk_zones tables) → отдельный PR
- Celery tasks (sync_territorial_zones_bbox, sync_zouit_*, sync_risk_*) →
  отдельный PR использует client
- Admin endpoints (trigger / status) → отдельный PR
- Замена on-demand fetch на nspd_client → PR after schema ready

Closes part of #94 (foundation only — sub-issues for schema/tasks).

* fix(scrapers): address PR #98 auto-review minor feedback

Backend (nspd_client.py):
1. (line 100+) bbox_around_point_m signature split на multi-line.
2. (lazy imports) import json, time → module-level.
3. (duplicate SSL ctx) Reuse _SSL_CTX из nspd_lite через explicit import.
4. (shape defense) list_layers чекает {"data": [...]} wrapper + non-dict/list garbage → warning + empty.
5. (cad_num docstring) search_by_cad документирует формат + ссылку на validate_cad_format.

Tests: +5 mock тестов (19/19 passed):
- get_feature_info URL builder + parsing
- get_features_in_bbox bbox propagation
- list_layers_walks_tree_response
- list_layers_handles_data_wrapper
- list_layers_handles_garbage_response

Backend (parcels.py): docstring '~25с' → '~15с'.

Per auto-review on be14388.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-12 09:47:45 +03:00
lekss361
49fac806fc
feat(site-finder): auto-fetch cadastre geometry on-demand (#93) (#95)
* feat(site-finder): auto-fetch cadastre geometry on-demand (#93)

Когда пользователь вводит cad-номер которого нет в БД (cad_quarters_geom /
cad_buildings / cad_parcels_geom), вместо 404 «Загрузи через NSPD geo» (dead-
end для non-admin) — теперь backend автоматически инициирует NSPD fetch,
frontend показывает loading state с polling /fetch-status каждые 2с.

Backend:
- Новый модуль app/services/site_finder/cadastre_fetch.py с helpers:
  find_or_enqueue_fetch (atomic check + enqueue с дедупликацией по cad),
  fetch_status (smart polling endpoint — отличает not_in_nspd от failed),
  detect_thematic_id (3-сегм quarter / 4-сегм parcel / 5-сегм building),
  validate_cad_format.
- Reuse: enqueue_geo_job + process_nspd_geo_job (workers/tasks/nspd_geo).
  source_kind='auto_on_demand' отличает от bulk; rate_ms=200, priority=9.
- POST /parcels/{cad}/analyze graceful fallback: inline await до 25s
  (fast path), затем 202 + job_id + eta_seconds для polling, либо
  400/404/503 в зависимости от статуса (с Retry-After 60s на 503).
- GET /parcels/{cad}/fetch-status новый endpoint для polling.

Frontend:
- useSiteAnalysis расширен: fetchingState + cancel(). POST analyze + при 202
  polling каждые 2с (max 60 итераций = 2 мин cap). status=ready → re-trigger
  analyze; not_in_nspd/failed/invalid_format → typed errors.
- apiFetchWithStatus<T> + HTTPError в lib/api.ts — status-aware variant
  для 200 vs 202.
- FetchingState.tsx: animated spinner, progress bar (linear до etaSeconds,
  asymptote после), elapsed counter, cancel button. Светло-голубая
  scheme отличается от обычного pending skeleton.
- site-finder/page.tsx: FetchingState когда fetchingState активен; обычный
  pending skeleton — только при первичном analyze без 202.

Edge cases (per #93 acceptance):
- cad валидный в НСПД, fetch <25s → inline 200 OK
- cad валидный, fetch >25s → 202 + frontend polls → ready → analyze
- cad валидный, не в НСПД → 404 с понятным сообщением + формат hint
- cad invalid format → 400 + формат hint
- NSPD rate-limited / failed → 503 + Retry-After 60s
- Параллельные запросы на тот же cad → один job, оба клиента poll'ят (дедуп
  через find_active_on_demand_job).

Closes #93.

* fix(site-finder): address PR #95 auto-review minor feedback

Backend (cadastre_fetch.py):
1. (race condition) Advisory lock `pg_try_advisory_xact_lock(hashtext(cad_num))`
   обёрнут вокруг шагов "check active job → enqueue" в find_or_enqueue_fetch.
   Lock transaction-scoped, released at COMMIT. Параллельные запросы на тот
   же cad: первый получает lock и enqueue; второй lock=false → re-check
   active job (уже виден после первого COMMIT) → возвращает тот же job_id.
   Docstring обновлён, упоминание SELECT FOR UPDATE удалено.

Backend (parcels.py):
3. (threadpool exhaustion) _INLINE_FETCH_WAIT_S снижен 25 → 15s с подробным
   комментарием: tradeoff про default Starlette anyio threadpool (40 slots)
   и concurrent burst saturation. 15s баланс: НСПД avg 5-15s для quarter,
   ~70% fast path; остальные 30% получают 202 + polling без блока.

Data (87_on_demand_indexes.sql):
2. (missing index) New migration:
   - `nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)` — для
     find_active/recent_completed_job (existing UNIQUE composite не покрывает
     WHERE cad_num=:c).
   - `nspd_geo_jobs_source_status_idx ON nspd_geo_jobs(source_kind, status)`
     composite для filter auto_on_demand + queued/running.
   Idempotent (CREATE INDEX IF NOT EXISTS), не блокер при текущем размере,
   критично при росте on-demand traffic.

Frontend (useSiteAnalysis.ts):
4. (UI flicker) setFetchingState(null) перемещён ПОСЛЕ `await second
   apiFetch`. Иначе между clear и resolve есть момент когда mutation
   isPending=true + fetchingState=null → пустой экран ~1 RTT.

NOT addressed (rebuttal):
5. (Tailwind convention) — проверил: в проекте нет ни globals.css, ни
   @tailwind directives. ВСЕ существующие site-finder components используют
   inline styles (ConfidenceBadge, GeotechRiskBlock, ScoreBreakdownPanel etc).
   Tailwind в deps но не wired up. Keep inline styles для consistency.
6. (animate-spin) — требует Tailwind globals (см. #5). `<style jsx>`
   keyframes — built-in Next.js, работает.

Per auto-review on 2252236.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-12 09:02:17 +03:00