5a7d405c77
Merge pull request 'feat(nspd): TIER 4 opportunity + red lines ( #94 PR2 of 4)' ( #220 ) from feat/94-pr2-opportunity-redlines into main
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 )
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
dbf31cc911
feat(etl): objective_complex_mapping backfill via pg_trgm ( #203 ) ( #214 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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
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
lekss361
f5794173de
fix(celery): disable nspd-harvest-stale-quarters beat (Bug post-mortem #1 ) ( #121 )
...
Per fixes/Bug_NSPD_WMS_NotBulk_2026_May14 action item #1 : отключить beat
schedule `nspd-harvest-stale-quarters` до Sprint 2 fix (grid sampling rewrite).
## Why
harvest_quarter в текущей реализации пишет почти пустые dumps в
nspd_quarter_dumps из-за single-pixel WMS GetFeatureInfo bug (детали в
vault post-mortem). Beat запуск Mon 04:00 МСК (next: 2026-05-18 04:00 МСК):
- Потратит rate-limit budget на ~12k EKB кварталов
- Заполнит nspd_quarter_dumps мусорными rows
- analyze_parcel начнёт отдавать пустые nspd_zoning/nspd_zouit_overlaps/
nspd_engineering_nearby для всех пользователей (через quarter_dump_lookup)
## What changed
`backend/app/workers/celery_app.py` — закомментирован `schedule[
"nspd-harvest-stale-quarters"]` блок (~10 строк) с inline-комментом
explanation + ссылкой на vault post-mortem.
## What НЕ changed
- `backend/app/workers/tasks/nspd_sync.py` — task code (harvest_quarter,
harvest_stale_quarters) остаётся, можно re-enable после Sprint 2 fix
- Admin endpoint для manual trigger остаётся работающим
- Все остальные beat tasks (poi-sync, noise-sync, zombie-cleanup,
refresh-ekb-districts-medians, kn-region-*) — не тронуты
## Vault
- fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md — status `identified` → `mitigated`
(уже обновлено через vault audit pass)
## Re-enable
После Sprint 2 grid-sampling rewrite (см. Bug post-mortem → Sprint 2
fix-strategy) — uncomment блок + verify через staging.
Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-14 23:24:53 +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