# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Handler functions for Real Estate feature routes. Contains extracted business logic from route handlers. """ import json import logging import re import aiohttp from typing import Optional, Dict, Any, List from fastapi import HTTPException, status from modules.datamodels.datamodelPagination import ( PaginationParams, PaginatedResponse, PaginationMetadata, ) from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from .datamodelFeatureRealEstate import ( Projekt, Parzelle, Dokument, DokumentTyp, Gemeinde, Kanton, Land, Kontext, StatusProzess, ) from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface from .mainRealEstate import ( create_project_with_parcel_data, extract_bzo_information, ) from .parcelSelectionService import is_parcel_adjacent_to_selection from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector from modules.connectors.connectorOerebWfs import OerebWfsConnector from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeFeatureRealEstate") logger = logging.getLogger(__name__) # ============================================================================ # GEMEINDE / BZO HANDLERS # ============================================================================ async def processGemeindenSync(interface, instanceId: str, mandateId: str, onlyCurrent: bool = True) -> Dict[str, Any]: """ Fetch all Gemeinden from Swiss Topo and save to DB for an instance. Creates Kantone as needed. """ try: oerebConnector = OerebWfsConnector() connector = SwissTopoMapServerConnector(oereb_connector=oerebConnector) gemeindenData = await connector.get_all_gemeinden(only_current=onlyCurrent) except Exception as e: logger.error(f"Error fetching Gemeinden from Swiss Topo: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Error fetching Gemeinden: {str(e)}") gemeindenCreated = 0 gemeindenSkipped = 0 kantoneCreated = 0 errors: List[str] = [] kantonCache: Dict[str, str] = {} def _findGemeindeByBfsNummer(bfsNummer: str) -> Optional[Any]: try: gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) for g in gemeinden: for k in (g.kontextInformationen or []): try: data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfsNummer): return g except (json.JSONDecodeError, AttributeError): continue except Exception as ex: logger.error(f"Error finding Gemeinde by BFS {bfsNummer}: {ex}", exc_info=True) return None def _getOrCreateKanton(kantonAbk: str) -> Optional[str]: nonlocal kantoneCreated, errors if not kantonAbk: return None if kantonAbk in kantonCache: return kantonCache[kantonAbk] kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kantonAbk}) if kantone: kantonCache[kantonAbk] = kantone[0].id return kantone[0].id kantonNames = { "AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden", "BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt", "FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden", "JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden", "OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn", "SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri", "VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich", } try: kantonLabel = kantonNames.get(kantonAbk, kantonAbk) kanton = Kanton( mandateId=mandateId, featureInstanceId=instanceId, label=kantonLabel, abk=kantonAbk, ) created = interface.createKanton(kanton) if created and created.id: kantonCache[kantonAbk] = created.id kantoneCreated += 1 return created.id except Exception as ex: errors.append(f"Error creating Kanton {kantonAbk}: {ex}") return None savedGemeinden: List[Dict[str, Any]] = [] for gd in gemeindenData: try: gemeindeName = gd.get("name") bfsNummer = gd.get("bfs_nummer") kantonAbk = gd.get("kanton") if not gemeindeName or bfsNummer is None: gemeindenSkipped += 1 continue existing = _findGemeindeByBfsNummer(str(bfsNummer)) if existing: gemeindenSkipped += 1 savedGemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing) continue kantonId = _getOrCreateKanton(kantonAbk) if kantonAbk else None gemeinde = Gemeinde( mandateId=mandateId, featureInstanceId=instanceId, label=gemeindeName, id_kanton=kantonId, kontextInformationen=[ Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfsNummer}, ensure_ascii=False)) ], ) created = interface.createGemeinde(gemeinde) if created and created.id: gemeindenCreated += 1 savedGemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created) else: errors.append(f"Failed to create Gemeinde {gemeindeName}") gemeindenSkipped += 1 except Exception as ex: errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}") gemeindenSkipped += 1 return { "gemeinden": savedGemeinden, "count": len(savedGemeinden), "stats": { "gemeinden_created": gemeindenCreated, "gemeinden_skipped": gemeindenSkipped, "kantone_created": kantoneCreated, "error_count": len(errors), "errors": errors[:10], }, } async def processBzoDocumentsFetch(interface, componentInterface, mandateId: str, instanceId: str) -> Dict[str, Any]: """Search for and download BZO documents for all Gemeinden of an instance.""" from modules.features.realEstate.realEstateGemeindeService import fetch_bzo_for_gemeinde gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) stats = {"gemeinden_processed": 0, "documents_created": 0, "documents_skipped": 0, "errors": []} results: List[Dict[str, Any]] = [] for gemeinde in gemeinden: gr = {"gemeinde_id": gemeinde.id, "gemeinde_label": gemeinde.label, "status": None, "dokument_ids": [], "error": None} try: stats["gemeinden_processed"] += 1 fetched = await fetch_bzo_for_gemeinde( interface, componentInterface, gemeinde, mandateId, instanceId ) if fetched: gr["status"] = "created" stats["documents_created"] += 1 refreshed = interface.getGemeinde(gemeinde.id) if refreshed and refreshed.dokumente: for doc in refreshed.dokumente: docId = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None) if docId: gr["dokument_ids"].append(docId) else: gr["status"] = "skipped" stats["documents_skipped"] += 1 except Exception as ex: gr["status"] = "error" gr["error"] = str(ex) stats["errors"].append(f"{gemeinde.label}: {str(ex)}") results.append(gr) return {"success": True, "stats": stats, "results": results} async def processParcelDocuments(interface, componentInterface, gemeindeName: str, bauzone: str, mandateId: str, instanceId: str) -> Dict[str, Any]: """ Ensure BZO document exists for Gemeinde, return documents for parcel info display. Creates Gemeinde (Swiss Topo) and BZO (Tavily) if not in DB. """ from modules.features.realEstate.realEstateGemeindeService import ( ensure_single_gemeinde, fetch_bzo_for_gemeinde, ) gemeindeObj = None byLabel = interface.getGemeinden(recordFilter={"label": gemeindeName, "mandateId": mandateId}) gemeindeObj = byLabel[0] if byLabel else None if not gemeindeObj: allG = interface.getGemeinden(recordFilter={"mandateId": mandateId}) gNorm = gemeindeName.strip().lower() for g in allG: gl = (g.label or "").strip().lower() if gl == gNorm or gNorm in gl or gl in gNorm: gemeindeObj = g logger.debug(f"parcel-documents: Found Gemeinde by label match '{gemeindeName}' -> '{g.label}'") break if gemeindeObj: logger.debug(f"parcel-documents: Gemeinde '{gemeindeName}' resolved: {gemeindeObj.id}") if not gemeindeObj: logger.info(f"parcel-documents: No Gemeinde for label '{gemeindeName}', ensuring via Swiss Topo...") gemeindeObj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeindeName) if not gemeindeObj: logger.warning(f"parcel-documents: Gemeinde '{gemeindeName}' nicht gefunden (mandateId={mandateId[:8]}...)") return {"documents": [], "error": f"Gemeinde '{gemeindeName}' nicht gefunden"} bzoDocs = [] if gemeindeObj.dokumente: for doc in gemeindeObj.dokumente: typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None) if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] or str(typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision"]: docId = doc.id if hasattr(doc, "id") else doc.get("id") if docId: full = interface.getDokument(docId) if full and full.dokumentReferenz: bzoDocs.append(full) if not bzoDocs: logger.info(f"parcel-documents: No BZO for {gemeindeName}, fetching...") fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeindeObj, mandateId, instanceId) if fetched: gemeindeObj = interface.getGemeinde(gemeindeObj.id) if gemeindeObj and gemeindeObj.dokumente: for doc in gemeindeObj.dokumente: typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None) if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]: docId = doc.id if hasattr(doc, "id") else doc.get("id") if docId: full = interface.getDokument(docId) if full and full.dokumentReferenz: bzoDocs.append(full) result = [] for d in bzoDocs: result.append({ "id": d.id, "label": d.label, "fileId": d.dokumentReferenz, "fileName": (d.label or "BZO") + ".pdf", "mimeType": d.mimeType or "application/pdf", }) return {"documents": result, "gemeinde": gemeindeName, "bauzone": bauzone} # ============================================================================ # LEGACY TABLE HANDLERS # ============================================================================ def processTableData(user, mandateId: Optional[str], table: str, pagination: Optional[str]) -> PaginatedResponse: """Fetch and paginate table data for a real estate entity table.""" tableMapping = { "Projekt": (Projekt, "getProjekte"), "Parzelle": (Parzelle, "getParzellen"), "Dokument": (Dokument, "getDokumente"), "Gemeinde": (Gemeinde, "getGemeinden"), "Kanton": (Kanton, "getKantone"), "Land": (Land, "getLaender"), } if table not in tableMapping: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}" ) realEstateInterface = getRealEstateInterface(user, mandateId=mandateId) modelClass, methodName = tableMapping[table] getterMethod = getattr(realEstateInterface, methodName) items = getterMethod(recordFilter=None) paginationParams = None if pagination: try: paginationDict = json.loads(pagination) paginationParams = PaginationParams(**paginationDict) if paginationDict else None except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid pagination parameter: {str(e)}" ) if paginationParams: if paginationParams.sort: for sortField in reversed(paginationParams.sort): fieldName = sortField.field direction = sortField.direction.lower() def _sortKey(item, _fieldName=fieldName): value = getattr(item, _fieldName, None) if value is None: return (1, None) return (0, value) items.sort(key=_sortKey, reverse=(direction == "desc")) totalItems = len(items) totalPages = (totalItems + paginationParams.pageSize - 1) // paginationParams.pageSize startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize paginatedItems = items[startIdx:endIdx] return PaginatedResponse( items=paginatedItems, pagination=PaginationMetadata( currentPage=paginationParams.page, pageSize=paginationParams.pageSize, totalItems=totalItems, totalPages=totalPages, sort=paginationParams.sort, filters=paginationParams.filters ) ) return PaginatedResponse(items=items, pagination=None) async def processCreateTableRecord(user, mandateId: Optional[str], table: str, data: Dict[str, Any]) -> Dict[str, Any]: """Create a record in a real estate table, with special handling for Projekt+parcel.""" if table == "Projekt" and ("parzelle" in data or "parzellen" in data): logger.info(f"Creating Projekt with parcel data for user {user.id}") label = data.get("label") if not label: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("label is required") ) statusProzess = data.get("statusProzess", "Eingang") parzellenData = [] if "parzellen" in data: parzellenData = data.get("parzellen", []) if not isinstance(parzellenData, list): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("parzellen must be an array") ) elif "parzelle" in data: parzelleData = data.get("parzelle") if parzelleData: parzellenData = [parzelleData] if not parzellenData: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("parzelle or parzellen data is required") ) result = await create_project_with_parcel_data( currentUser=user, mandateId=mandateId, projekt_label=label, parzellen_data=parzellenData, status_prozess=statusProzess, ) return result.get("projekt", {}) tableMapping = { "Projekt": (Projekt, "createProjekt"), "Parzelle": (Parzelle, "createParzelle"), "Dokument": (Dokument, "createDokument"), "Gemeinde": (Gemeinde, "createGemeinde"), "Kanton": (Kanton, "createKanton"), "Land": (Land, "createLand"), } if table not in tableMapping: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}" ) realEstateInterface = getRealEstateInterface(user, mandateId=mandateId) modelClass, methodName = tableMapping[table] createMethod = getattr(realEstateInterface, methodName) if "mandateId" not in data: data["mandateId"] = mandateId try: modelInstance = modelClass(**data) except Exception as e: logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid data for {table}: {str(e)}" ) createdRecord = createMethod(modelInstance) if hasattr(createdRecord, 'model_dump'): return createdRecord.model_dump() return createdRecord # ============================================================================ # PARCEL SEARCH HANDLER # ============================================================================ async def processParcelSearch(user, mandateId: Optional[str], location: str, includeBauzone: bool, includeAdjacent: bool) -> Dict[str, Any]: """ Search for parcel information by address or coordinates. Resolves address, calculates geometry, optionally fetches adjacent parcels and bauzone. """ connector = SwissTopoMapServerConnector() parcelData = await connector.search_parcel(location) if not parcelData: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No parcel found for location: {location}" ) extractedAttributes = connector.extract_parcel_attributes(parcelData) attributes = parcelData.get("attributes", {}) geometry = parcelData.get("geometry", {}) areaM2 = None centroid = None if extractedAttributes.get("perimeter"): perimeter = extractedAttributes["perimeter"] points = perimeter.get("punkte", []) if len(points) >= 3: area = 0 for i in range(len(points)): j = (i + 1) % len(points) area += points[i]["x"] * points[j]["y"] area -= points[j]["x"] * points[i]["y"] areaM2 = abs(area / 2) sumX = sum(p["x"] for p in points) sumY = sum(p["y"] for p in points) centroid = {"x": sumX / len(points), "y": sumY / len(points)} canton = attributes.get("ak", "") municipalityName = None fullAddress = None plz = None geocodedAddress = parcelData.get('geocoded_address') if geocodedAddress: fullAddress = geocodedAddress.get('full_address') plz = geocodedAddress.get('plz') municipalityName = geocodedAddress.get('municipality') logger.debug(f"Using geocoded address: {fullAddress}") queryCoords = parcelData.get('query_coordinates') addressQueryCoords = queryCoords if queryCoords else centroid if not fullAddress and addressQueryCoords: queryX = addressQueryCoords['x'] queryY = addressQueryCoords['y'] logger.debug(f"Querying address layer at query coordinates: ({queryX}, {queryY})") isCoordinateSearch = ',' in location and not any(c.isalpha() for c in location.split(',')[0]) buildingTolerance = 1 if isCoordinateSearch else 10 buildingResult = await connector._query_building_layer(queryX, queryY, tolerance=buildingTolerance, buffer=25) if buildingResult: addrAttrs = buildingResult.get("attributes", {}) logger.debug(f"Address layer attributes: {addrAttrs}") addressInfo = connector._extract_address_from_building_attrs(addrAttrs) fullAddress = addressInfo.get('full_address') plz = addressInfo.get('plz') municipalityName = addressInfo.get('municipality') if fullAddress: logger.debug(f"Constructed address: {fullAddress}") if not fullAddress: if location and any(c.isalpha() for c in location) and "CH" not in location: fullAddress = location logger.debug(f"Using location as address: {fullAddress}") if not municipalityName and fullAddress: plzMunicipalityMatch = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", fullAddress) if plzMunicipalityMatch: extractedMunicipality = plzMunicipalityMatch.group(2).strip() extractedMunicipality = re.sub(r"[,;\.]+$", "", extractedMunicipality).strip() if extractedMunicipality: municipalityName = extractedMunicipality if not plz: plz = plzMunicipalityMatch.group(1) logger.debug(f"Extracted municipality from address: {municipalityName}") bfsnr = attributes.get("bfsnr") if not municipalityName and bfsnr and canton and mandateId: try: interface = getRealEstateInterface(user, mandateId=mandateId, featureInstanceId=None) gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) for g in gemeinden: for k in (g.kontextInformationen or []): try: data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt if isinstance(data, dict): bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code") if str(bfs) == str(bfsnr): municipalityName = g.label logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipalityName}") break except (json.JSONDecodeError, AttributeError): continue if municipalityName: break except Exception as e: logger.debug(f"Error querying Gemeinde by BFS: {e}") if not municipalityName and centroid and canton: try: geocodeUrl = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" params = { "geometry": f"{centroid['x']},{centroid['y']}", "geometryType": "esriGeometryPoint", "layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill", "tolerance": "0", "returnGeometry": "false", "sr": "2056", "f": "json", } async with aiohttp.ClientSession() as session: async with session.get(geocodeUrl, params=params) as resp: if resp.status == 200: data = await resp.json() results = data.get("results", []) if results: attrs = results[0].get("attributes", {}) geoName = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label") if geoName: municipalityName = connector._clean_municipality_name(str(geoName)) logger.debug(f"Found municipality via Swiss Topo geocoding: {municipalityName}") except Exception as e: logger.debug(f"Error querying Swiss Topo geocoding: {e}") if not municipalityName and bfsnr: commonMunicipalities = { 261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur", 351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne", 1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz", } if bfsnr in commonMunicipalities: municipalityName = commonMunicipalities[bfsnr] logger.debug(f"Looked up municipality from common list: {municipalityName}") elif canton and bfsnr: municipalityName = f"{canton}-{bfsnr}" logger.debug(f"Using fallback municipality: {municipalityName}") if fullAddress and fullAddress.startswith("CH") and len(fullAddress) == 14 and fullAddress[2:].isdigit(): fullAddress = None logger.debug("Removed EGRID from address field") bauzone = None hasGeometry = geometry and (geometry.get("rings") or geometry.get("coordinates")) if includeBauzone and canton and hasGeometry and centroid: try: logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}") oerebConnector = OerebWfsConnector() zoneResults = await oerebConnector.query_zone_layer( egrid=attributes.get("egris_egrid", "") or "", x=centroid["x"], y=centroid["y"], canton=canton, geometry=geometry, ) if zoneResults and len(zoneResults) > 0: zoneAttrs = zoneResults[0].get("attributes", {}) typGdeAbkuerzung = zoneAttrs.get("typ_gde_abkuerzung") if typGdeAbkuerzung: bauzone = typGdeAbkuerzung logger.debug(f"Found bauzone: {bauzone} for parcel {attributes.get('label')}") except Exception as e: logger.warning(f"Error querying zone information: {e}", exc_info=True) parcelInfo = { "id": attributes.get("label") or attributes.get("number"), "egrid": attributes.get("egris_egrid"), "number": attributes.get("number"), "name": attributes.get("name"), "identnd": attributes.get("identnd"), "canton": attributes.get("ak"), "municipality_code": attributes.get("bfsnr"), "municipality_name": municipalityName, "address": fullAddress, "plz": plz, "perimeter": extractedAttributes.get("perimeter"), "area_m2": areaM2, "centroid": centroid, "geoportal_url": attributes.get("geoportal_url"), "realestate_type": attributes.get("realestate_type"), "bauzone": bauzone, } bbox = parcelData.get("bbox", []) mapView = { "center": centroid, "zoom_bounds": { "min_x": bbox[0] if len(bbox) >= 4 else None, "min_y": bbox[1] if len(bbox) >= 4 else None, "max_x": bbox[2] if len(bbox) >= 4 else None, "max_y": bbox[3] if len(bbox) >= 4 else None }, "geometry_geojson": { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [ [[p["x"], p["y"]] for p in extractedAttributes["perimeter"]["punkte"]] ] if extractedAttributes.get("perimeter") else [] }, "properties": { "id": parcelInfo["id"], "egrid": parcelInfo["egrid"], "number": parcelInfo["number"] } } } responseData = { "parcel": parcelInfo, "map_view": mapView } if includeAdjacent and parcelData and parcelData.get("geometry"): try: selectedParcelId = parcelInfo["id"] adjacentParcelsRaw = await connector.find_neighboring_parcels( parcel_data=parcelData, selected_parcel_id=selectedParcelId, sample_distance=20.0, max_sample_points=30, max_neighbors=15, max_concurrent=50, ) adjacentParcels = [_convertParcelGeometry(adjParcel) for adjParcel in adjacentParcelsRaw] responseData["adjacent_parcels"] = adjacentParcels logger.info(f"Found {len(adjacentParcels)} neighboring parcels for parcel {selectedParcelId}") except Exception as e: logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True) responseData["adjacent_parcels"] = [] return responseData def _convertParcelGeometry(adjParcel: Dict[str, Any]) -> Dict[str, Any]: """Convert an adjacent parcel to include GeoJSON geometry.""" adjParcelWithGeo = { "id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number"), "perimeter": adjParcel.get("perimeter") } adjGeometry = adjParcel.get("geometry") adjPerimeter = adjParcel.get("perimeter") if adjGeometry: if "rings" in adjGeometry and adjGeometry["rings"]: ring = adjGeometry["rings"][0] coordinates = [[[p[0], p[1]] for p in ring]] adjParcelWithGeo["geometry_geojson"] = { "type": "Feature", "geometry": {"type": "Polygon", "coordinates": coordinates}, "properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")} } elif adjGeometry.get("type") == "Polygon": adjParcelWithGeo["geometry_geojson"] = { "type": "Feature", "geometry": adjGeometry, "properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")} } if "geometry_geojson" not in adjParcelWithGeo and adjPerimeter and adjPerimeter.get("punkte"): punkte = adjPerimeter["punkte"] coordinates = [[[p["x"], p["y"]] for p in punkte]] adjParcelWithGeo["geometry_geojson"] = { "type": "Feature", "geometry": {"type": "Polygon", "coordinates": coordinates}, "properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")} } return adjParcelWithGeo # ============================================================================ # ADD ADJACENT PARCEL HANDLER # ============================================================================ async def processAddAdjacentParcel(location: Dict[str, Any], selectedParcels: List[Dict[str, Any]]) -> Dict[str, Any]: """ Add an adjacent parcel to the selection. Validates adjacency. Returns parcel response with geometry. """ locStr = f"{location['x']},{location['y']}" connector = SwissTopoMapServerConnector() parcelData = await connector.search_parcel(locStr) if not parcelData: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("No parcel found at this location") ) extracted = connector.extract_parcel_attributes(parcelData) attributes = parcelData.get("attributes", {}) geometry = parcelData.get("geometry", {}) areaM2 = None centroid = None if extracted.get("perimeter"): perimeter = extracted["perimeter"] points = perimeter.get("punkte", []) if len(points) >= 3: area = 0 for i in range(len(points)): j = (i + 1) % len(points) area += points[i]["x"] * points[j]["y"] area -= points[j]["x"] * points[i]["y"] areaM2 = abs(area / 2) sumX = sum(p["x"] for p in points) sumY = sum(p["y"] for p in points) centroid = {"x": sumX / len(points), "y": sumY / len(points)} parcelInfo = { "id": attributes.get("label") or attributes.get("number"), "egrid": attributes.get("egris_egrid"), "number": attributes.get("number"), "name": attributes.get("name"), "identnd": attributes.get("identnd"), "canton": attributes.get("ak"), "municipality_code": attributes.get("bfsnr"), "municipality_name": None, "address": None, "plz": None, "perimeter": extracted.get("perimeter"), "area_m2": areaM2, "centroid": centroid, "geoportal_url": attributes.get("geoportal_url"), "realestate_type": attributes.get("realestate_type"), "bauzone": None, } mapView = { "center": centroid, "zoom_bounds": parcelData.get("bbox", []) and { "min_x": parcelData["bbox"][0], "min_y": parcelData["bbox"][1], "max_x": parcelData["bbox"][2], "max_y": parcelData["bbox"][3], } or None, "geometry_geojson": _buildGeometryGeojson(extracted, parcelInfo), } newParcelResponse = {"parcel": parcelInfo, "map_view": mapView} if not is_parcel_adjacent_to_selection(newParcelResponse, selectedParcels): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden") ) bbox = parcelData.get("bbox", []) mapView["zoom_bounds"] = { "min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3] } if len(bbox) >= 4 else None geocodedAddress = parcelData.get("geocoded_address") if geocodedAddress: parcelInfo["municipality_name"] = geocodedAddress.get("municipality") parcelInfo["address"] = geocodedAddress.get("full_address") parcelInfo["plz"] = geocodedAddress.get("plz") if centroid and attributes.get("ak"): try: oereb = OerebWfsConnector() zoneResults = await oereb.query_zone_layer( egrid=attributes.get("egris_egrid", "") or "", x=centroid["x"], y=centroid["y"], canton=attributes.get("ak"), geometry=geometry, ) if zoneResults and len(zoneResults) > 0: parcelInfo["bauzone"] = zoneResults[0].get("attributes", {}).get("typ_gde_abkuerzung") except Exception as oe: logger.debug(f"ÖREB zone query failed: {oe}") return newParcelResponse def _buildGeometryGeojson(extracted: Dict[str, Any], parcelInfo: Dict[str, Any]) -> Dict[str, Any]: """Build geometry_geojson from extracted perimeter.""" coords = [] if extracted.get("perimeter", {}).get("punkte"): coords = [[[p["x"], p["y"]] for p in extracted["perimeter"]["punkte"]]] return { "type": "Feature", "geometry": {"type": "Polygon", "coordinates": coords}, "properties": {"id": parcelInfo["id"], "egrid": parcelInfo["egrid"], "number": parcelInfo["number"]}, } # ============================================================================ # ADD PARCEL TO PROJECT HANDLER # ============================================================================ async def processAddParcelToProject(user, mandateId: Optional[str], projektId: str, body: Dict[str, Any]) -> Dict[str, Any]: """ Add a parcel to an existing project. Supports linking existing, creating from location, or creating from custom data. """ realEstateInterface = getRealEstateInterface(user, mandateId=mandateId) recordFilter = {"id": projektId} if mandateId: recordFilter["mandateId"] = mandateId projekte = realEstateInterface.getProjekte(recordFilter=recordFilter) if not projekte: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Projekt {projektId} not found" ) projekt = projekte[0] parcelId = body.get("parcelId") location = body.get("location") parcelDataDict = body.get("parcelData") parzelle = None if parcelId: logger.info(f"Linking existing parcel {parcelId}") parcelFilter = {"id": parcelId} if mandateId: parcelFilter["mandateId"] = mandateId parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter) if not parcels: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Parzelle {parcelId} not found" ) parzelle = parcels[0] elif location: logger.info(f"Creating parcel from location: {location}") connector = SwissTopoMapServerConnector() parcelData = await connector.search_parcel(location) if not parcelData: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No parcel found at location: {location}" ) extractedAttributes = connector.extract_parcel_attributes(parcelData) attributes = parcelData.get("attributes", {}) parzelleCreateData = { "mandateId": mandateId, "label": extractedAttributes.get("label") or attributes.get("number") or "Unknown", "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], "eigentuemerschaft": None, "strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None, "plz": None, "perimeter": extractedAttributes.get("perimeter"), "baulinie": None, "kontextGemeinde": None, "bauzone": None, "az": None, "bz": None, "vollgeschossZahl": None, "anrechenbarDachgeschoss": None, "anrechenbarUntergeschoss": None, "gebaeudehoeheMax": None, "regelnGrenzabstand": [], "regelnMehrlaengenzuschlag": [], "regelnMehrhoehenzuschlag": [], "parzelleBebaut": None, "parzelleErschlossen": None, "parzelleHanglage": None, "laermschutzzone": None, "hochwasserschutzzone": None, "grundwasserschutzzone": None, "parzellenNachbarschaft": [], "dokumente": [], "kontextInformationen": [ Kontext( thema="Swiss Topo Data", inhalt=json.dumps({ "egrid": attributes.get("egris_egrid"), "identnd": attributes.get("identnd"), "canton": attributes.get("ak"), "municipality_code": attributes.get("bfsnr"), "geoportal_url": attributes.get("geoportal_url") }, ensure_ascii=False) ) ] } parzelleInstance = Parzelle(**parzelleCreateData) parzelle = realEstateInterface.createParzelle(parzelleInstance) elif parcelDataDict: logger.info(f"Creating parcel from custom data") parcelDataDict["mandateId"] = mandateId parzelleInstance = Parzelle(**parcelDataDict) parzelle = realEstateInterface.createParzelle(parzelleInstance) else: raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required") if parzelle not in projekt.parzellen: projekt.parzellen.append(parzelle) if not projekt.perimeter and parzelle.perimeter: projekt.perimeter = parzelle.perimeter updatedProjekt = realEstateInterface.updateProjekt(projekt) logger.info(f"Added Parzelle {parzelle.id} to Projekt {projektId}") return { "projekt": updatedProjekt.model_dump(), "parzelle": parzelle.model_dump() }