from __future__ import annotations from decimal import Decimal, ROUND_HALF_UP from django.db import transaction from location_data.models import SoilLocation from location_data.serializers import SoilDepthDataSerializer from location_data.tasks import fetch_soil_data_for_coordinates from plant.serializers import PlantSerializer from weather.services import update_weather_for_location from weather.models import WeatherForecast from .models import SensorData from .serializers import WeatherForecastDetailSerializer DEPTH_PRIORITY = ["0-5cm", "5-15cm", "15-30cm"] DECIMAL_PRECISION = Decimal("0.000001") class ExternalDataSyncError(Exception): """خطا در همگام‌سازی داده از سرویس‌های بیرونی.""" def get_farm_details(farm_uuid: str): farm = ( SensorData.objects.select_related("center_location", "weather_forecast") .prefetch_related("plants", "center_location__depths") .filter(farm_uuid=farm_uuid) .first() ) if farm is None: return None center_location = farm.center_location weather = farm.weather_forecast if weather is None: weather = ( center_location.weather_forecasts.order_by("-forecast_date", "-id").first() ) depths = list(center_location.depths.all()) depths.sort(key=lambda item: DEPTH_PRIORITY.index(item.depth_label) if item.depth_label in DEPTH_PRIORITY else 99) soil_metrics = _surface_soil_metrics(depths) sensor_metrics = _flatten_sensor_metrics(farm.sensor_payload) resolved_metrics = dict(soil_metrics) metric_sources = {key: "soil" for key in soil_metrics} for key, value in sensor_metrics.items(): resolved_metrics[key] = value metric_sources[key] = "sensor" return { "farm_uuid": farm.farm_uuid, "center_location": { "id": center_location.id, "lat": center_location.latitude, "lon": center_location.longitude, "farm_boundary": center_location.farm_boundary, }, "weather": WeatherForecastDetailSerializer(weather).data if weather else None, "sensor_payload": farm.sensor_payload or {}, "soil": { "resolved_metrics": resolved_metrics, "metric_sources": metric_sources, "depths": SoilDepthDataSerializer(depths, many=True).data, }, "plant_ids": list(farm.plants.values_list("id", flat=True)), "plants": PlantSerializer(farm.plants.all(), many=True).data, "created_at": farm.created_at, "updated_at": farm.updated_at, } def resolve_center_location_from_boundary(farm_boundary: dict | list) -> SoilLocation: """ مرز مزرعه را می‌گیرد، مرکز را محاسبه می‌کند و رکورد SoilLocation را ایجاد/به‌روزرسانی می‌کند. """ points = _extract_boundary_points(farm_boundary) if not points: raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") normalized_points = _normalize_points(points) if len(normalized_points) < 3: raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") lat_sum = sum(lat for lat, _ in normalized_points) lon_sum = sum(lon for _, lon in normalized_points) count = Decimal(len(normalized_points)) center_lat = (lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP) center_lon = (lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP) with transaction.atomic(): location, _ = SoilLocation.objects.update_or_create( latitude=center_lat, longitude=center_lon, defaults={"farm_boundary": _serialize_boundary(farm_boundary)}, ) return location def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | None: return ( WeatherForecast.objects.filter(location=location) .order_by("-forecast_date", "-id") .first() ) def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocation, WeatherForecast | None]: """ اگر داده خاک یا آب‌وهوا برای location موجود نباشد، از سرویس مربوطه واکشی و در دیتابیس ذخیره می‌شود. """ if not location.is_complete: try: soil_result = fetch_soil_data_for_coordinates( latitude=float(location.latitude), longitude=float(location.longitude), ) except Exception as exc: raise ExternalDataSyncError(f"خطا در واکشی داده خاک: {exc}") from exc if soil_result.get("status") != "completed": raise ExternalDataSyncError( soil_result.get("error") or "واکشی داده خاک کامل نشد." ) location.refresh_from_db() weather_forecast = resolve_weather_for_location(location) if weather_forecast is None: weather_result = update_weather_for_location(location) if weather_result.get("status") not in {"success", "no_data"}: raise ExternalDataSyncError( weather_result.get("error") or "واکشی داده آب‌وهوا کامل نشد." ) weather_forecast = resolve_weather_for_location(location) return location, weather_forecast def _flatten_sensor_metrics(sensor_payload: dict | None) -> dict: if not isinstance(sensor_payload, dict): return {} flattened = {} for sensor_values in sensor_payload.values(): if not isinstance(sensor_values, dict): continue flattened.update(sensor_values) return flattened def _surface_soil_metrics(depths) -> dict: if not depths: return {} primary_depth = depths[0] fields = [ "bdod", "cec", "cfvo", "clay", "nitrogen", "ocd", "ocs", "phh2o", "sand", "silt", "soc", "wv0010", "wv0033", "wv1500", ] return { field: getattr(primary_depth, field) for field in fields if getattr(primary_depth, field) is not None } def _extract_boundary_points(boundary: dict | list) -> list: if isinstance(boundary, dict): if boundary.get("type") == "Polygon": coordinates = boundary.get("coordinates") or [] if coordinates and isinstance(coordinates[0], list): return coordinates[0] return [] if "corners" in boundary: return boundary.get("corners") or [] if isinstance(boundary, list): return boundary return [] def _normalize_points(points: list) -> list[tuple[Decimal, Decimal]]: normalized: list[tuple[Decimal, Decimal]] = [] for point in points: lat = lon = None if isinstance(point, dict): lat = point.get("lat", point.get("latitude")) lon = point.get("lon", point.get("longitude")) elif isinstance(point, (list, tuple)) and len(point) >= 2: lon, lat = point[0], point[1] if lat is None or lon is None: continue lat_decimal = Decimal(str(lat)) lon_decimal = Decimal(str(lon)) normalized.append((lat_decimal, lon_decimal)) if len(normalized) > 1 and normalized[0] == normalized[-1]: normalized = normalized[:-1] return normalized def _serialize_boundary(boundary: dict | list) -> dict: if isinstance(boundary, dict) and boundary.get("type") == "Polygon": return boundary raw_points = boundary.get("corners") if isinstance(boundary, dict) else boundary normalized = _normalize_points(raw_points or []) coordinates = [[float(lon), float(lat)] for lat, lon in normalized] if coordinates and coordinates[0] != coordinates[-1]: coordinates.append(coordinates[0]) return { "type": "Polygon", "coordinates": [coordinates], }