feat(nspd): TIER 3 risk zones (#94 PR1 of 4) #217
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#217
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/94-pr1-risk-zones"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Sub-PR 1 of 4 для #94 (NSPD extended layers).
harvest_stale_quartersfanout теперь передаётinclude_risks=True→ 11 TIER 3 layers (подтопление, оползни, гари, эрозии и т.п.) попадают вnspd_quarter_dumps.features_json/analyzeизвлекает risk zones пересекающие участок:ST_Intersection(...::geography)для metric areaNspdRiskZonesBlock— severity color-coded badges (🔴 затопление/оползни, 🟡 подтопление/гари, 🟠 эрозии)_extract_features_by_layer— generic для re-use в PR2 (opportunity + red lines)Roadmap (#94 decomposition)
nspd_parcels+nspd_buildings(~380 LOC)Files (9, +545 LOC)
backend/app/workers/tasks/nspd_sync.py(+5) — fanout flagbackend/app/services/site_finder/quarter_dump_lookup.py(+156) —_RISK_SUBTYPE_LABELS+_extract_features_by_layer+_get_risk_zonesbackend/app/schemas/parcel.py(+12) —RiskZonebackend/app/api/v1/parcels.py(+5) — analyze response fieldbackend/tests/services/test_quarter_dump_lookup.py(+202) — 11 testsfrontend/src/components/site-finder/NspdRiskZonesBlock.tsx(+147)frontend/src/components/site-finder/LandTab.tsx(+14)frontend/src/types/nspd.ts(+9)frontend/src/types/site-finder.ts(+1)Test plan
/api/v1/parcels/analyzeна cad_num с известной risk zone (например quarter под подтоплением) → response содержитnspd_risk_zones[]harvest_stale_quarters.delay()→ проверить логи чтоinclude_risks=Trueпереданcd backend && uv run pytest tests/services/test_quarter_dump_lookup.py -v→ 11/11 passPre-push checks
risks_count/features_jsonverified против live DB, SRID consistency черезST_SetSRID+ST_Transform, no f-string SQL, no XSS)Known follow-ups
NspdRiskZonesBlock.getSeverity) explicitly covers 4 из 11 layers; остальные default → "low". Расширить в PR2 если нужно тонкое разделение._get_risk_zonesиспользует bareexcept Exception(consistent с sibling helpers); narrow to(OperationalError, ProgrammingError)— отдельный refactor PR.Part of #94
Code Review — verdict
Summary
1595157· Mergeable: true · Auto-merge scope: BLOCKED (api/v1, services, workers)Critical
None.
High
None.
Medium
None.
Low
quarter_dump_lookup._get_risk_zonesuses bareexcept Exception— acknowledged in PR body as consistent with sibling helpers (_get_zoning,_get_zouit_overlaps,_get_engineering_nearby). Acceptable for now; tracked as follow-up to narrow to(OperationalError, ProgrammingError).NspdRiskZonesBlock.getSeverityexplicitly maps only 4 of 11 risk layers; the other 7 fall through to"low". Per PR body this is intentional — расширить в PR2. UI still renders subtype label correctly via_RISK_SUBTYPE_LABELSfallback in backend.NspdRiskZonesBlockuseskey={idx}for the sorted list. Stable enough for a static analyze response, but layer+geom_wkt hash would be more correct if list ever updates client-side.intersection_area_sqmrounded to 1 decimal (round(val, 1)); the UI then re-rounds viaMath.round/toFixed(2)for hectares. No data loss but two rounding stages is slightly redundant.Schema verification
Verified against live DB (
public.nspd_quarter_dumps):risks_count(integer NOT NULL default 0) — exists ✅features_json(jsonb NOT NULL default'[]'::jsonb) — exists ✅_get_risk_zonesreferences only real columns (quarter_cad,features_json) +jsonb_array_elements— no phantom columns.ST_SetSRID(ST_GeomFromGeoJSON(...), 3857) → ST_Transform(..., 4326)matches the convention used by all sibling helpers (zouit, engineering) in the same file — consistent ✅.NSPDClient.QUARTER_RISK_LAYERSstores features withlayer = "risk_<short_name>"; PR1's_RISK_SUBTYPE_LABELSkeys are short_names and looked up vialayer.removeprefix("risk_")→ mapping aligns ✅.RiskZonefields (layer,subtype,geom_wkt,intersection_area_sqm) exactly match the dict shape returned by_get_risk_zones✅.Security audit
:q,:wkt) — no f-string / format / % interpolation. ✅zone.subtypeandzone.layerare plain strings rendered inside React text nodes (nodangerouslySetInnerHTML, no URL construction) — auto-escaped by React. No XSS surface. ✅safeUrl()needed (no URL fields in this PR).Correctness
ST_Intersection(...::geography)returns metric m² → correct for area ✅._extract_features_by_layerguards non-stringlayerviaisinstance(...,str); empty list returns[]— edge cases covered._get_risk_zonesearly-exits whenrisks_count == 0(skips heavyjsonb_array_elementsscan) — good perf guard./analyze✅.backend/tests/services/test_quarter_dump_lookup.py): 11 tests covering extract / intersect / no-intersect / no-risks-in-dump / db-exception / null-area / non-string layer / label fallback (landslide→ "Обвально-осыпные процессы") /make_empty_resultincludes new key /derive_quarter_cadsmoke (parametrized 5 cases). Coverage matches PR claim ✅.LandTab.tsx: block conditionally rendered whendata.nspd_risk_zones !== undefined; component itself shows green "Риски не обнаружены" fallback when array empty ✅.harvest_quarteralready acceptsinclude_risks: bool = Falsekwarg (line 241 ofnspd_sync.py) — fanout change inharvest_stale_quartersis signature-compatible. Other call-sites (e.g.harvest_quarter.delay(cad)without kwarg) still work, just default toFalse— no breakage ✅.Conventions
psycopg2imports, norequests, usestext(...) + :parambinding (no CAST trap encountered since no type casts on params).RiskZoneinterface explicit, noany, nouseEffectfor fetch (data comes via parent prop drilling). ✅Nspd*Blockcomponents in the project — not Tailwind, but follows existing pattern).Cross-file impact
parcels.py,LandTab.tsx,quarter_dump_lookup.py, orsite-finder.ts.parcels.pyanalyze response gains one new fieldnspd_risk_zones: list[RiskZone](always present, defaults to[]vianspd_dump_data.get(...)). Frontend type marks it?:optional. Non-breaking for existing consumers — codegen will add an optional array property.harvest_stale_quartersfanout change is backward-compatible (see Correctness section).make_empty_resultreturns new keynspd_risk_zones: []— anything destructuring this dict (e.g. analyze handlernspd_dump_data["nspd_risk_zones"]) will Just Work; older callers ignoring it are unaffected.Auto-merge policy
BLOCKED for auto-merge per
.claude/rules/git-pr.mdscope policy: PR touchesbackend/app/api/v1/parcels.py,backend/app/services/site_finder/*, andbackend/app/workers/tasks/*— all three are in the blocked-list (api/v1, services, workers). Human merge required despite APPROVE verdict.