Commit graph

148 commits

Author SHA1 Message Date
5a7d405c77 Merge pull request 'feat(nspd): TIER 4 opportunity + red lines (#94 PR2 of 4)' (#220) from feat/94-pr2-opportunity-redlines into main
All checks were successful
Deploy / changes (push) Successful in 4s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-worker (push) Successful in 2m44s
Deploy / build-frontend (push) Successful in 3m10s
Deploy / deploy (push) Successful in 41s
2026-05-16 17:09:52 +00:00
lekss361
fdb54834e5 fix(nspd): rename migration 89→98, fix red lines ST_Area→ST_Length (#220)
- data/sql/98_*: rename from 89 to avoid collision with existing 89_drop_dead_brin
- _get_red_lines: ST_Length(ST_Intersection(planar)::geography) instead of
  ST_Area(ST_Intersection(::geography, ::geography)) — fixes PostGIS 3.4
  tolerance error and returns correct non-zero length for LINESTRING intersections
- Rename intersection_area_sqm → intersection_length_m across schema, TS types,
  frontend component, and tests; add test_get_red_lines_db_exception_returns_empty

Addresses review-bot feedback on PR #220.
2026-05-16 19:33:14 +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
fbb76812f4 fix(recommend): cad_buildings.floors is INTEGER, not TEXT
PROD 500 on /api/v1/analytics/recommend/mix:
  psycopg.errors.UndefinedFunction: operator does not exist: integer ~ unknown
  LINE 19: AND ((cb.floors ~ '^[0-9]+$' AND cb.f...

_district_cadastre_baseline use regex \ on cb.floors. Schema is INTEGER
(verified information_schema), so the regex defence against text values
('1-2', '2-3' etc) is misplaced — simplify to floors >= 3 with NULL safety
via OR purpose ILIKE '%многокв%' fallback.
2026-05-16 18:29:35 +03:00
b61f025673 feat(backend): sentry-sdk init для FastAPI + Celery (#204 backend) (#207)
Some checks failed
Deploy / build-backend (push) Has been cancelled
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 4s
Deploy / build-worker (push) Has been cancelled
2026-05-16 15:09:16 +00: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
76b1f8673f test(infra): integration phantom column gate (#197) (#216)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m48s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 4m11s
Deploy / deploy (push) Successful in 53s
2026-05-16 13:07:13 +00:00
dbf31cc911 feat(etl): objective_complex_mapping backfill via pg_trgm (#203) (#214)
All checks were successful
Deploy / changes (push) Successful in 4s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m36s
Deploy / deploy (push) Successful in 38s
2026-05-16 12:47:09 +00: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
adc45f5724 feat(permits): ЕКБ РНС/РВЭ xlsx parser + schema + ETL (#105 PR A) (#211)
All checks were successful
Deploy / changes (push) Successful in 4s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 39s
2026-05-16 11:41:21 +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
efa66a9f68 feat(analytics): mv_quarter_price_per_m2 MV + refresh helper (#33 D1 PR A) (#209)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 54s
2026-05-16 11:12:01 +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
6aa22ef8ba feat(analytics): mv_layout_velocity MV + refresh helper (#113 PR B) (#194)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 48s
2026-05-16 08:26:59 +00:00
e673811a20 feat(parcels): layout signature schemas + extractor (#113 PR A) (#193)
All checks were successful
Deploy / changes (push) Successful in 4s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m1s
Deploy / deploy (push) Successful in 49s
2026-05-16 07:52:34 +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
50547dcbb3 test(deploy): validate Forgejo Actions end-to-end flow (#189)
Some checks failed
CI / backend (push) Successful in 1m40s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Failing after 11s
Deploy / build-worker (push) Failing after 11s
CI / frontend (push) Successful in 2m13s
Deploy / build-frontend (push) Failing after 12s
Deploy / deploy (push) Failing after 0s
2026-05-15 21:55:04 +00:00
lekss361
4d492ebc50
perf(cadastre): freshness skip + grid-walk heartbeat + log noise reduction (#187)
* perf(cadastre): freshness skip + grid-walk heartbeat + reduce WMS log noise

Three operational improvements after ekb_full v3 completed at 99.1%:

Fix H — per-quarter freshness skip (scrape_cadastre.py):
- enqueue_cadastre_harvest queries cad_quarter_stats.fetched_at per quarter
- Skip quarters where fetched_at >= NOW() - skip_fresh_hours (default 1h)
- extra_config.skip_fresh_hours=0 disables skip (full re-fetch)
- Saves ~30k NSPD calls on repeated runs of same scope

Fix I — heartbeat in grid-walk loop (bulk_harvest.py:_grid_walk_category):
- 225 cells x 70-300ms = 15-75s heartbeat silence
- Pass update_progress callback to _grid_walk_category
- Call every heartbeat_every (50) cells with phase progress info
- Prevents cleanup_zombies false-positive cancellation

Fix J — NSPD WMS HTTP 500 noise to debug (bulk_harvest.py:_grid_walk_category):
- NSPD WMS endpoint frequently returns HTTP 500 (server-side issue)
- Was: logger.warning each, 200+ warnings per quarter
- Now: logger.debug (visible via --log-level=DEBUG)

40 tests passing.

* fix(cadastre): parse NSPD DD.MM.YYYY dates (Fix K — job 18 root cause)

Job 18 (manual_list recovery 11 quarters) crashed every quarter with:
  psycopg.errors.DatetimeFieldOverflow: date/time field value out of range: '13.03.2023'

NSPD returns registration_date in mixed formats:
- DD.MM.YYYY for Сооружения / ОНС / ЕНК / ЗОУИТ
- YYYY-MM-DD ISO for ЗУ / Здания
- YYYY-MM-DD HH:MM:SS with time

PG CAST(:date AS date) accepts ISO only — DD.MM.YYYY crashes.

Add _parse_nspd_date helper: handles both formats + None/empty.
Apply in upsert_construction, upsert_onc, upsert_enk, upsert_zouit, upsert_parcel
for registration_date / build_record_registration_date / legal_act_date.

3 new unit tests for the helper (43 tests total).

* fix(cadastre): address bot blockers — pivot Fix H to raw_targets + tests

Bot review on PR #187 caught:
1. cadastre_jobs has no extra_config column → Fix H crashed on KeyError
2. Missing tests for Fix H + Fix I
3. _parse_nspd_date didn't validate date ranges

Changes:
- Pivot Fix H: read skip_fresh_hours from raw_targets JSONB (existing column),
  no schema migration needed
- _parse_nspd_date: validate via date.fromisoformat() — rejects 2023-13-45
- Add 3 tests for Fix H in backend/tests/workers/test_scrape_cadastre.py
- Add 2 tests for Fix I heartbeat callbacks in test_cadastre_bulk.py
- Add 1 test for parse_nspd_date range validation (was: only format)

49 tests passing.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 23:02:37 +03:00
lekss361
d104222643
fix(cadastre,nspd_sync): SQL syntax bugs surfaced by ekb_full v2 (#186)
Three distinct SQL syntax errors caught from worker logs:

1. **upsert_zouit** (bulk_harvest.py:940) — trailing comma after 4326
   in ST_Transform(..., 3857), 4326,) → 'syntax error at or near ")"'.
   Hit on 8+ quarters in 0108 block (66:41:0108055/61/69/77 etc).
   Likely introduced by ruff-format auto-comma on closing paren.

2. **nspd_sync** (nspd_sync.py:120) — `:fetched_at_utc::timestamptz`
   psycopg double-colon trap → 'syntax error at or near ":"'.
   Crashed legacy on-demand harvest_quarter called from analyze.

3. **job_settings** (job_settings.py:224) — `:extra_config::jsonb`
   same double-colon trap in admin settings UPDATE.

All converted to CAST(:x AS type) pattern (psycopg v3 safe).

40 tests still passing.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 19:18:43 +03:00
lekss361
5f8df655cc
fix(cadastre): cad_parcels.geom Polygon -> MultiPolygon (migration 93) (#185)
NSPD returns MultiPolygon for Многоконтурный участок (e.g. 66:41:0105017:4)
which crashed upsert_parcel — schema was strict POLYGON.

Migration 93: ALTER COLUMN geom Polygon -> MultiPolygon USING ST_Multi(geom),
DROP+RECREATE GIST index + back-compat VIEW cad_parcels_geom.

upsert_parcel SQL wraps ST_Transform in ST_Multi() — coerces Polygon to
MultiPolygon. NSPD returns both types (simple + multi-contour parcels).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 18:52:38 +03:00
lekss361
e3359c4f03
fix(cadastre): filter Point/LineString geometry in upsert_parcel/zouit (#184)
cad_parcels.geom is geometry(Polygon, 4326) — strict schema. NSPD
occasionally returns Point geometry for parcels without detailed
boundary, causing INSERT failure:
  psycopg.errors.InvalidParameterValue:
  Geometry type (Point) does not match column type (Polygon)

This killed pilot v8 at quarter 66:41:0104002 — worker autoretry
exhausted, job hung at 25/50 (heartbeat stale).

Fix: filter geometry.type at Python level in upsert_parcel + upsert_zouit
(same pattern as upsert_quarter_geom_from_feature). Non-Polygon
geometry → geom=NULL, raw_props preserved.

Tables with permissive GEOMETRY schema (cad_buildings, cad_constructions,
cad_enk, cad_oncs) unaffected — they accept any geometry type.

Tests: 3 new (Point parcel → geom=NULL, Polygon happy path, LineString zouit).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 18:37:29 +03:00
lekss361
e695bca1e4
fix(cadastre): remove shared phase_state early-exit (race condition) (#183)
cadastre_jobs.phase_state is shared between ALL parallel Celery workers
of the same job. The early-exit triggered as soon as the FIRST worker
wrote phase=done via progress_cb — all subsequent workers (reading same
shared row) bailed without work.

Effect: pilot v7 made 5 NSPD requests for 50 quarters (=0.1/quarter).
Full ekb_full job #8: 4950 req / 2408 quarters = 2/quarter — 95% workers
early-exited without doing snapshot phase.

Root cause of 1.6% parcels coverage. Fix A/B/C only partially helped
because most workers never reached Phase 1/1.5.

Solution: delete the early-exit. Idempotency is guaranteed by
ON CONFLICT DO UPDATE in every upsert_* — re-enqueued tasks
write same rows without duplicates.

Closes part of #168 (root-cause fix for race condition revealed by #182 metrics).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 17:41:27 +03:00
lekss361
057f8891dc
fix(cadastre): count snapshot+per-cat requests in cadastre_jobs.requests_count (#182)
scrape_cadastre.py previously incremented requests_count only by
grid_walk_requests, so snapshot-only quarters (and the entire ekb_full
job after PR #179 skips grid-walk on broken geom) reported 0 NSPD
requests — misleading for observability.

Add HarvestResult.snapshot_requests counter (Phase 1 search + Phase 1.5
per-cat probes) and a total_requests property. Update Celery task to
write total_requests into cadastre_jobs.requests_count.

Tests added for the new property + Phase 1 snapshot_requests=1 assertion.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 17:17:01 +03:00
lekss361
905ef443fc
fix(cadastre): auto-heal cad_quarters_geom from CAT_QUARTER_STATS feature (#181)
72% of EKB quarters (1735/2408) have broken micro-precision geom in
cad_quarters_geom. NSPD returns valid quarter polygon in CAT_QUARTER_STATS
(36381) feature but Phase 4 was only consuming stats.

Add upsert_quarter_geom_from_feature: CTE-based UPDATE that transforms
3857 polygon to 4326 and writes only when existing geom is NULL or broken
(bbox width outside 100-10000m), verified via SQL-side sanity check.
Wire into Phase 4 with begin_nested savepoint: malformed NSPD GeoJSON
(self-intersecting ring) rollbacks only geom UPDATE, preserves
upsert_quarter_stats.

Closes part of #168 (follow-up data quality fix for #179).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 17:16:39 +03:00
lekss361
d5b57ccbf1
feat(cadastre): per-category NSPD snapshot — Phase 1.5 (ZOUIT/ENK) (#180)
Switch base search_by_quarter from thematicSearchId=1 to thematic=1
(includes ZOUIT in features). Add optional category_id param that routes
ZOUIT (36940/469039/469040/469042) -> thematicSearchId=5,
ENK (39663) -> thematicSearchId=15.

Add Phase 1.5 in harvest_quarter: for each PER_CATEGORY_PROBE_CATS
category present in meta_counts (meta_total>0), run dedicated
per-category search call. Skips if category in GRID_WALK_CATS and
bbox is valid (grid-walk gives better coverage).

Curl-probe confirmed NSPD API caps at 20/category with no pagination;
categoryId param is server-ignored; thematic=1 strictly better than
thematicSearchId=1 (includes ZOUIT).

Closes part of #168 (ZOUIT/ENK coverage gap).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 16:53:49 +03:00
lekss361
3beb75d68a
fix(cadastre): skip grid-walk for broken quarter geometries (#168) (#179)
* fix(cadastre): skip grid-walk for broken quarter geometries (#168)

Pilot v5 (50 quarters) разоблачил что cad_quarters_geom имеет 1735/2408 (72%)
ЕКБ rows с broken micro-precision geom (width ~0.01м instead of 200-4000м).
Causes:
- grid-walk WMS receives bbox ~1mm × 1mm → 500 ServiceException на all 450 req
- One quarter hung 2+ min (silent failure in async loop)

Real fix: re-import cad_quarters_geom geometry from NSPD (category 36381).
Tracked в follow-up issue.

Quick fix (this PR): sanity-check quarter_bbox_3857 — return None если width
ИЛИ height not in [100m, 10000m]. Bulk_harvest_quarter then skips grid-walk
для broken quarters, только snapshot phase запускается (20 per cat).

After this fix:
- All 50 pilot quarters получают snapshot (parcels/buildings/etc up to 20 each)
- Grid-walk runs only для 673/2408 healthy quarters → no 500 cascades, no hangs

* test(cadastre): add quarter_bbox_3857 boundary tests (PR #179 bot blocker)

- healthy bbox 750m -> returns tuple
- width <100m (broken micro-precision) -> None
- width >10000m (oversized) -> None
- height <100m (symmetry) -> None
- row missing (quarter not in cad_quarters_geom) -> None

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 16:25:06 +03:00
lekss361
6dfff5e046
fix(cadastre): CAST :geom AS text — psycopg AmbiguousParameter (#168) (#178)
Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 16:02:03 +03:00
lekss361
99e2210919
fix(cadastre): NSPD response parse — properties.category + top-level meta (#168) (#177)
* fix(cadastre): NSPD response parse — properties.category + top-level meta (#168)

* fix(cadastre): test fixtures use real NSPD shape — properties.category + top-level meta (#168)

Bot review of #177 flagged that fixtures still used legacy properties.categoryId
+ data.meta — meaning CI exercised only fallback branches, not primary paths
where the actual bug lived. Pattern of 3 consecutive "live-only" parse fixes
(#175 verify, #176 headers, #177 parse) confirmed need for test coverage.

Changes:
- test_nspd_bulk_client.py: sample_quarter_response → properties.category +
  meta moved to top-level (real NSPD shape verified live)
- test_cadastre_bulk.py: 7 fixtures categoryId → category (regex replace)
- test_nspd_bulk_feature_parse_basic: primary path now exercised

Plus schema hardening per bot review:
- NSPDBulkFeature.category_id: `is not None` check (not truthy `or`)
  to avoid edge case category=0; int() wrapped in try/except so
  non-numeric ID (e.g. "ЗУ") doesn't crash upsert_features loop.

35/35 tests pass locally.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 15:40:01 +03: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
8dee9546ae
fix(cadastre): NSPDBulkClient verify=False for NSPD SSL chain (#168 hotfix) (#175)
Pilot run #1 failed on all 50 quarters with:
  NspdBulkError: [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate
  in certificate chain

NSPD prod chain on Beget VPS contains an internal/self-signed CA →
httpx default verify=True trips. Reuse pattern from legacy
app.services.scrapers.nspd_lite (uses ssl._create_unverified_context()
for same reason since April 2026).

Risk profile: NSPD public gov data, no sensitive payload outbound,
admin token не уходит к NSPD endpoints. MITM risk minimal.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 14:50:21 +03:00
lekss361
5da66bbc93
feat(cadastre): bulk_harvest worker + grid-walker + admin API (#168 PR3/5) (#171)
* feat(cadastre): bulk_harvest_quarter Celery task + grid-walker + saga state (#168 PR3/5)

Add bulk cadastre harvest pipeline:
- services/cadastre/bulk_harvest.py: async harvest_quarter() orchestrator (4 phases)
  + 7 upsert helpers (parcels/buildings/constructions/oncs/enks/zouit/quarter_stats)
  using CAST(:x AS jsonb) pattern, begin_nested() SAVEPOINT per grid-walk upsert
- services/cadastre/grid_geometry.py: quarter_bbox_3857 (PostGIS ST_Extent)
  + generate_grid_click_points (15x15 = 225 sub-bbox grid)
- workers/tasks/scrape_cadastre.py: bulk_harvest_quarter_task (acks_late=True,
  dont_autoretry_for NspdBulkWafError), enqueue_cadastre_harvest, cleanup_zombies
- api/v1/admin_cadastre.py: 5 endpoints behind AdminTokenAuth — create/list/get/cancel/resume
- Tests: 13 service unit + 8 API tests

* fixup(cadastre): drop importorskip (PR2 merged) — imports now top-level (#168 PR3)

* fixup(cadastre): tile_size unified param + register slow marker (#168 PR3)

Blocker #1: rename tile_width/tile_height → tile_size in generate_grid_click_points.
Callers in bulk_harvest.py and test_cadastre_bulk.py used tile_size; def had
tile_width+tile_height → TypeError at runtime.

Blocker #2 (10 failures in test_admin_cadastre.py) was side-effect of #1:
TypeError at import chain (app.main → admin_cadastre → bulk_harvest)
broke FastAPI app load → dependency_overrides AttributeError. Now resolved.

Also register `slow` pytest marker in pyproject.toml to suppress
PytestUnknownMarkWarning.

* fixup(cadastre): use importlib.util.find_spec instead of import (#168 PR3)

Root cause Blocker #2: `import app.workers.tasks.scrape_cadastre` at module
top-level REBOUND `app` from FastAPI instance (line 14) to the Python package.
All subsequent `app.dependency_overrides[get_db] = ...` failed with
AttributeError because `app` was now the namespace module.

Fix: probe module availability with importlib.util.find_spec — does not
import or rebind names. FastAPI `app` instance stays intact.

All 28 cadastre tests pass locally.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 13:31:32 +03:00
lekss361
b7889d113b
feat(cadastre): NSPDBulkClient + schemas for bulk ingest (#168 PR2/5) (#170)
Add async NSPD client (nspd_bulk_client.py) with 3 new methods:
- search_by_quarter: REST /api/geoportal/v2/search/geoportal — snapshot 20 per cat + meta totals
- wms_feature_info: WMS GetFeatureInfo для grid-walk discovery
- list_objects_in_building: tab-group-data (Q3 deferred — код ready)

Rate limit: asyncio.Semaphore(3) + 0.05s jitter.
429 → exponential backoff 2→4→8s, max 3 retries.
403 (WAF) → raise immediately, no retry.

Pydantic schemas (nspd_bulk.py): NSPDBulkFeature, QuarterSnapshot, ObjectsListing
with extra='allow' to preserve unknown fields → raw_props JSONB downstream.

15 unit tests + 4 e2e (real NSPD, skipped in CI by default).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 12:50:41 +03:00
lekss361
d7fbaa0528
feat(velocity): per-room-bucket breakdown in VelocityResult (#163)
* fix(velocity): :window_interval::interval cast syntax — same bug as PR #152

SQLAlchemy text() parser confuses ':window_interval' (named param) followed
by '::interval' (cast operator) — exactly как было в PR #152 с :weights::jsonb.

Result: psycopg видит literal ':window_interval' в SQL → syntax error →
exception caught by velocity catch → return None → UI shows null.

## Fix

':window_interval::interval' → 'CAST(:window_interval AS interval)' (2 мест:
sales_rows query + _get_ekb_median percentile).

Pre-push code-reviewer должен был catch — добавим в feedback что для каждого
text() SQL grep ':[a-z]*::' before push.

Refs: PR #158 deploy verify, e2e velocity = null root cause

* feat(velocity): add per-room-bucket breakdown to VelocityResult

Add third SQL query (bucket_rows) aggregating deals_total_count by
room_bucket ('студия','1','2','3','4+') across mapped competitors.

New fields:
- VelocityResult.by_room_bucket: aggregate {units, sqm, complexes_count}
- sample_competitors[].by_room_bucket: {bucket: units}

Bucket query wrapped в SAVEPOINT — failure degrades gracefully к empty
dict без abort outer tx.

CAST(:window_interval AS interval) pattern per PR #160 lesson.

Test coverage: 3 new tests (aggregation, empty fallback, sample entries).

Closes part of #161

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 10:09:03 +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
46584d7c75
fix(velocity): :window_interval::interval cast syntax — same bug as PR #152 (#160)
SQLAlchemy text() parser confuses ':window_interval' (named param) followed
by '::interval' (cast operator) — exactly как было в PR #152 с :weights::jsonb.

Result: psycopg видит literal ':window_interval' в SQL → syntax error →
exception caught by velocity catch → return None → UI shows null.

## Fix

':window_interval::interval' → 'CAST(:window_interval AS interval)' (2 мест:
sales_rows query + _get_ekb_median percentile).

Pre-push code-reviewer должен был catch — добавим в feedback что для каждого
text() SQL grep ':[a-z]*::' before push.

Refs: PR #158 deploy verify, e2e velocity = null root cause

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 09:46:01 +03:00
lekss361
8fa1d005fd
feat(velocity): migrate D2 sales source → objective_corpus_room_month (#158)
* feat(velocity): migrate D2 sales source from stale sale_graph to objective_corpus_room_month

Closes #156

* fix(velocity): cross-stack contract + NULL safety per pre-push review

3 pre-push code-reviewer findings fixed:

1. TS union extended ('objective'|'sale_graph'|'rosreestr_fallback')
   + UI conditions handle both objective и sale_graph как valid sources.
2. COALESCE(deals_total_vol_m2, deals_total_count * 45.0) — NULL safety
   for DKP-only rows (vol_m2 nullable, count > 0).
3. room_bucket parking filter — verified false positive (все 5 buckets
   apartments: студия/1/2/3/4+).

Refs: PR #157 pre-push code-reviewer

* fix(types): add 'objective' to velocity_source union (cherry-pick miss)

Cherry-pick от закрытого PR #157 потерял этот файл. Frontend type-check fails
с 'no overlap' error. Restoring.

Refs: PR #158 frontend CI failure

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 09:34:00 +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
af39e95f83
fix(site-finder): SQLAlchemy syntax error :weights::jsonb → CAST(:weights AS jsonb) (#152)
Production POST /weight-profiles → 500 SyntaxError.

## Root cause

SQLAlchemy text() parser трактует ':weights' followed by '::jsonb' двусмысленно:
- ':weights' — named param binding (должен стать %(weights)s)
- '::jsonb' — PG cast operator

Parser НЕ распознаёт :weights как параметр когда сразу следует :: cast.
Result: psycopg видит literal ':weights' в SQL → syntax error.

```
LINE 5: ($1, $2, :weights::jsonb, $3, $4)
                   ^
parameters: {user_id, profile_name, is_default, description}
            ↑ note: 'weights' missing — SQLAlchemy skipped it
```

## Fix

Replace ambiguous :weights::jsonb с CAST(:weights AS jsonb):
- _INSERT (line 102)
- _UPDATE dynamic 'weights = :weights::jsonb' (line 273)

CAST() syntax не имеет ambiguity, SQLAlchemy корректно bind'ит :weights.

## Affected endpoints

- POST /api/v1/admin/site-finder/weight-profiles (create)
- PUT /api/v1/admin/site-finder/weight-profiles/{id} (update with weights)

Refs: user report 2026-05-15

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 08:12:18 +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
5aea78c2d8
feat(site-finder): weight profiles service — Pydantic + CRUD (#114 sub-PR 2/4) (#137)
Per #114 sub-PR 2/4. Pydantic v2 models + CRUD service. 14 passing tests.

API: list/get/get_default/create/update/delete_profile + resolve_weights
fallback to system defaults.

Validation: ALLOWED_CATEGORIES guard + weight bounds [-2.0, 3.0].
Default-uniqueness в одной transaction (job_settings.py pattern).

Vault: code/modules/Module_Weight_Profiles_Service.md.

Refs: #114

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 00:29:37 +03:00
lekss361
6a256bfc1f
refactor(workers): split celery_app.py god-object into 3 modules (#135)
Per audit batch #127 P3 god objects (issue #134).

Split layout:
- celery_app.py: 270 → 35 lines (config only)
- beat_schedule.py: 207 lines NEW (DB-driven + fallback + hardcoded)
- lifecycle.py: 168 lines NEW (worker_process_init + worker_ready handlers)

No business logic changes — move-only structural refactor. Public API
(celery_app.conf.beat_schedule) unchanged.

Verify:
- beat keys: kn-region-66, objective_sync, refresh-ekb-districts-medians,
  nspd-geo-zombie-cleanup, poi-sync-weekly, noise-sync-weekly
- ruff + ruff-format clean
- AST parse OK

Vault: Module_Beat_Schedule + Module_Worker_Lifecycle NEW;
Module_Celery_App updated.

Closes #134
Refs: #127

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 00:09:38 +03:00
lekss361
389fd31fad
refactor(admin): extract _check_token into shared AdminTokenAuth dep (#133)
Per audit batch #127 P2 hygiene (issue #128).

## Problem

Identical `_check_token` function в 3 admin files:
- backend/app/api/v1/admin_scrape.py:37-44
- backend/app/api/v1/admin_leads.py:26-33
- backend/app/api/v1/admin_jobs.py:24-31

## Fix

New `backend/app/core/deps.py` с `verify_admin_token` + Annotated alias:
```python
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
```

Заменено в 27 endpoints (18 admin_scrape + 6 admin_leads + 3 admin_jobs):
- Removed local `_check_token` definitions
- `_: None = Depends(_check_token)` → `_: AdminTokenAuth`
- Removed unused imports (`Header`, `HTTPException`, `settings` где не используется)

## Semantics preserved

Все 3 original implementations были identical (503 if env missing + 401 if
mismatch, same header `X-Admin-Token`, same env `SCRAPE_ADMIN_TOKEN`).

Минор: 503 detail text unified to `"admin disabled — set SCRAPE_ADMIN_TOKEN"`
(в оригинале три разные строки — `"admin scrape disabled..."` etc).
Cosmetic — detail в HTTP 503 не парсится клиентским кодом.

## Tests

`uv run pytest tests/ -x -k admin` → 64/64 passed.

## Vault

`code/patterns/Pattern_Admin_Token_Dependency_May14.md` — created.

Closes #128
Refs: #127

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-14 23:45:44 +03:00
lekss361
a3e7d21e3a
fix(pzz_loader): replace bare db.rollback() with SAVEPOINT per-row (#124)
Per code review audit (May 14): transaction correctness bug в
`sync_pzz_zones_to_db()` — inner `db.rollback()` в loop сбрасывал outer
transaction на per-row failures → counts inserted/updated НЕ matched DB state.

## Fix

Wrap UPSERT в loop через `db.begin_nested()` (SAVEPOINT):
- Failure одной row → rollback только её savepoint, outer tx alive
- Successfully inserted rows accumulate в outer tx → commit final
- Outer try/except добавлен для unexpected error → rollback + log + raise
- `finally: db.close()` preserved

Pattern consistent с `domrf_kn.py` (где SAVEPOINT уже работает).

## Preserved

- Function signature + return shape {fetched, inserted, updated, skipped} — unchanged
- Logging contracts
- skipped count теперь корректно включает DB-failure rows (consistent with prior behavior)

## Tests

- No test_pzz_loader.py — skipped
- ruff + AST passed

## Vault

`fixes/Bug_Pzz_Loader_Missing_Savepoint_May14.md` — created (status: resolved).

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-14 23:29:23 +03:00
lekss361
8dfab7613d
fix(nspd-geo): replace f-string bulk INSERT with parametrized batch execute (#123)
Per code review audit (May 14): real SQL injection vector в `enqueue_geo_job`.
Manual `chr(39)*2` quote escape НЕ injection-safe — не защищает от backslash
escape, Unicode quote tricks. Если cad_num когда-либо придёт из user input —
exploitable.

## Change

`backend/app/workers/tasks/nspd_geo.py:298-310` — заменил f-string VALUES
concatenation на SQLAlchemy `text(...)` + list of dicts (psycopg v3 handles
batch executemany под капотом).

Pattern consistent с остальными INSERT'ами в этом же файле (_log,
_start_job, _save_quarter, _save_parcel, _save_building).

## Preserved

- ON CONFLICT (job_id, cad_num, thematic_id) DO NOTHING — verbatim
- Return value (int(job_id))
- Surrounding tx/commit/close logic
- DB schema, function signature, caller interface — unchanged

## Tests

- No test_nspd_geo.py существует — skipped
- ruff check passed

## Vault

`fixes/Bug_Nspd_Geo_Sql_Injection_May14.md` — created (status: resolved).

No backward compat / migration concerns — pure in-place query rewrite.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-14 23:29:12 +03:00