platform-core/modules/features/realEstate/handlerRealEstate.py
ValueOn AG cf0233f193
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 13s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
refactor: architecture cleanup + fix scheduler Automation2Workflow error
Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot)
Refactor: gdprDeletion via onUserDelete lifecycle hooks
Refactor: i18nBootSync accounting labels via app.py parameter injection
Refactor: serviceHub moved to serviceCenter/serviceHub.py
Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling
Cleanup: remove obsolete methodTrustee, serviceExceptions shim
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 07:59:31 +02:00

949 lines
39 KiB
Python

"""
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()
}