diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 05ac0fec..e3c72620 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -415,21 +415,31 @@ def _aggregate_pipeline(rows: list[Any]) -> dict[str, Any]: # Sort quarters chronologically quarters_sorted = [{"quarter": k, **v} for k, v in sorted(by_quarter.items())] - # Top objects — по flat_count desc - top_objects = sorted( - [dict(r) for r in rows], - key=lambda r: r.get("flat_count") or 0, - reverse=True, - )[:PIPELINE_TOP_OBJECTS_LIMIT] - - # Serialize date for JSON; distance_m — explicit None check, не falsy, - # потому что centroid-on-building даёт 0.0 (falsy) и raw Decimal упал бы - # в JSON serialization. - for obj in top_objects: - if obj.get("ready_dt"): - obj["ready_dt"] = obj["ready_dt"].isoformat() - if obj.get("distance_m") is not None: - obj["distance_m"] = round(float(obj["distance_m"])) + # Top objects — по flat_count desc. + # Explicit field selection вместо `dict(r)` — иначе CTE `SELECT *` протекает + # внутренние колонки (latitude/longitude/snapshot_date/region_cd/dev_id и т.д.) + # в API response. Не security issue, но schema-leak. + top_rows = sorted(rows, key=lambda r: r.get("flat_count") or 0, reverse=True)[ + :PIPELINE_TOP_OBJECTS_LIMIT + ] + top_objects: list[dict[str, Any]] = [] + for r in top_rows: + ready_dt = r.get("ready_dt") + distance_m = r.get("distance_m") + top_objects.append( + { + "obj_id": r["obj_id"], + "comm_name": r.get("comm_name"), + "dev_name": r.get("dev_name"), + "obj_class": r.get("obj_class"), + "flat_count": r.get("flat_count"), + # ISO date string для JSON; distance_m — explicit None guard + # (centroid-on-building даёт 0.0 — falsy float; raw Decimal иначе + # упадёт в JSON serialization). + "ready_dt": ready_dt.isoformat() if ready_dt else None, + "distance_m": round(float(distance_m)) if distance_m is not None else None, + } + ) return { "objects_count": len(rows), @@ -1159,7 +1169,7 @@ def analyze_parcel( # (latitude, longitude) — отдельный issue для database-expert. pipeline_rows = ( db.execute( - text(f""" + text(""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) * FROM domrf_kn_objects @@ -1181,13 +1191,17 @@ def analyze_parcel( WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - {PIPELINE_RADIUS_M} + :radius_m ) AND ready_dt >= CURRENT_DATE - AND ready_dt < CURRENT_DATE + INTERVAL '{PIPELINE_HORIZON_MONTHS} months' + AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval) ORDER BY ready_dt ASC """), - {"wkt": geom_wkt}, + { + "wkt": geom_wkt, + "radius_m": PIPELINE_RADIUS_M, + "horizon_months": str(PIPELINE_HORIZON_MONTHS), + }, ) .mappings() .all() diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index b2fafee8..e4b28fa9 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -185,7 +185,10 @@ export interface PipelineObject { obj_class: string | null; flat_count: number | null; ready_dt: string | null; - distance_m: number; + // backend имеет explicit `is not None` guard для distance_m (centroid-on- + // building даёт 0.0 которое falsy, raw Decimal иначе упал бы в JSON), так + // что nullable отражает defensive Python path. + distance_m: number | null; } export interface Pipeline24mo {