"""Pusat pull exports: ops (cycle metrics) and IoT (flock panels).""" from __future__ import annotations from datetime import date, timedelta from typing import Any from django.db.models import QuerySet from django.utils import timezone from django.utils.dateparse import parse_date from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.serializers import CycleSerializer from apps.operations.models import ( AIInsight, ChickenCounting, ChickenWeight, FeedSacks, IotPanel, KPI, ManualInput, ) from apps.operations.serializers import ( AIInsightSerializer, ChickenCountingSerializer, ChickenWeightSerializer, FeedSacksSerializer, IotPanelSerializer, KPISerializer, ManualInputSerializer, ) from apps.operations.services.karung_web import ( initial_balance_accuracy, pull_initial_balance_iot, ) DEFAULT_PAGE_SIZE = 500 MAX_PAGE_SIZE = 2000 class PusatExportError(Exception): def __init__(self, message: str): self.message = message super().__init__(message) def _parse_page(raw_page: str | None, raw_size: str | None) -> tuple[int, int]: try: page = int(raw_page or "1") page_size = int(raw_size or str(DEFAULT_PAGE_SIZE)) except (TypeError, ValueError) as exc: raise PusatExportError("page dan page_size harus angka.") from exc if page < 1: raise PusatExportError("page harus >= 1.") if page_size < 1 or page_size > MAX_PAGE_SIZE: raise PusatExportError(f"page_size harus antara 1 dan {MAX_PAGE_SIZE}.") return page, page_size def _parse_optional_date(value: str | None, label: str) -> date | None: if value is None or str(value).strip() == "": return None parsed = parse_date(str(value).strip()) if parsed is None: raise PusatExportError(f"{label} tidak valid (gunakan YYYY-MM-DD).") return parsed def _default_window() -> tuple[date, date]: today = timezone.localdate() return today - timedelta(days=6), today def _paginate(qs: QuerySet, *, page: int, page_size: int) -> dict[str, Any]: total = qs.count() start = (page - 1) * page_size end = start + page_size return { "count": total, "page": page, "page_size": page_size, "results": list(qs[start:end]), } def _enrich_site_id(rows: list[dict], site_id: str) -> list[dict]: for row in rows: row["site_id"] = site_id return rows def _serialize_page( qs: QuerySet, serializer_class, *, page: int, page_size: int, site_id: str, ) -> dict[str, Any]: page_data = _paginate(qs, page=page, page_size=page_size) serialized = serializer_class(page_data["results"], many=True).data page_data["results"] = _enrich_site_id(list(serialized), site_id) return page_data def _site_payload(site: Site) -> dict[str, Any]: return { "site_id": site.pk, "site_name": site.site_name, "pusat_code": site.pusat_code or "", "pusat_active_site_id": site.pusat_active_site_id, "pusat_registered_at": site.pusat_registered_at, "created_at": site.created_at, "updated_at": site.updated_at, } def _kandang_payload(kandang: Kandang, *, include_feed_urls: bool = True) -> dict[str, Any]: data: dict[str, Any] = { "kandang_id": kandang.pk, "kandang_name": kandang.kandang_name, "created_at": kandang.created_at, "updated_at": kandang.updated_at, } if include_feed_urls: data["feed_in_button_urls"] = kandang.feed_in_button_urls or [] return data def resolve_ops_export_params( *, site_id: str | None, cycle_id: str | None, date_from: str | None, date_to: str | None, page: str | None, page_size: str | None, ) -> dict[str, Any]: if not site_id or not str(site_id).strip(): raise PusatExportError("site_id wajib diisi.") site_id = str(site_id).strip() try: site = Site.objects.get(pk=site_id) except Site.DoesNotExist as exc: raise PusatExportError("site_id tidak ditemukan.") from exc cycle: Cycle | None = None if cycle_id is not None and str(cycle_id).strip() != "": try: cycle = Cycle.objects.select_related("kandang").get(pk=int(cycle_id)) except (Cycle.DoesNotExist, TypeError, ValueError) as exc: raise PusatExportError("cycle_id tidak ditemukan.") from exc if cycle.kandang.site_id != site.pk: raise PusatExportError("cycle_id tidak termasuk site_id tersebut.") parsed_from = _parse_optional_date(date_from, "from") parsed_to = _parse_optional_date(date_to, "to") if parsed_from is None and parsed_to is None: if cycle is not None: parsed_from = cycle.start_date parsed_to = cycle.end_date or timezone.localdate() else: parsed_from, parsed_to = _default_window() elif parsed_from is None or parsed_to is None: raise PusatExportError("from dan to harus diisi berpasangan, atau keduanya dikosongkan.") if parsed_to < parsed_from: raise PusatExportError("to harus pada atau setelah from.") page_n, page_size_n = _parse_page(page, page_size) return { "site": site, "cycle": cycle, "date_from": parsed_from, "date_to": parsed_to, "page": page_n, "page_size": page_size_n, } def _enrich_cycle_initial_balance(cycle: Cycle, row: dict[str, Any]) -> dict[str, Any]: """Attach live IoT saldo-awal comparison fields (same source as initial-balance API).""" balance_date = cycle.feed_initial_balance_date if balance_date is None: row["feed_initial_balance_iot"] = None row["feed_initial_balance_accuracy"] = None row["feed_initial_balance_sync_error"] = None return row iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date) manual = cycle.feed_initial_balance row["feed_initial_balance_iot"] = iot_in row["feed_initial_balance_accuracy"] = initial_balance_accuracy(manual, iot_in) row["feed_initial_balance_sync_error"] = sync_error return row def build_ops_export( *, site: Site, cycle: Cycle | None, date_from: date, date_to: date, page: int, page_size: int, ) -> dict[str, Any]: kandangs_qs = Kandang.objects.filter(site=site).order_by("kandang_id") cycles_qs = Cycle.objects.filter(kandang__site=site).select_related("kandang") if cycle is not None: kandangs_qs = kandangs_qs.filter(pk=cycle.kandang_id) cycles_qs = cycles_qs.filter(pk=cycle.pk) sites_tree: list[dict[str, Any]] = [] site_node = _site_payload(site) kandang_nodes = [] cycles_by_kandang: dict[int, list[Cycle]] = {} for c in cycles_qs.order_by("cycle_id"): cycles_by_kandang.setdefault(c.kandang_id, []).append(c) for kandang in kandangs_qs: node = _kandang_payload(kandang, include_feed_urls=True) cycle_rows = [] for c in cycles_by_kandang.get(kandang.pk, []): row = CycleSerializer(c).data cycle_rows.append(_enrich_cycle_initial_balance(c, dict(row))) node["cycles"] = cycle_rows kandang_nodes.append(node) site_node["kandangs"] = kandang_nodes sites_tree.append(site_node) series_filter = {"cycle__kandang__site_id": site.pk, "date__gte": date_from, "date__lte": date_to} if cycle is not None: series_filter["cycle_id"] = cycle.pk def series(model, serializer_class, order_fields=("date", "pk"), override_filter=None): fltr = override_filter if override_filter is not None else series_filter qs = ( model.objects.filter(**fltr) .select_related("cycle", "cycle__kandang") .order_by(*order_fields) ) return _serialize_page( qs, serializer_class, page=page, page_size=page_size, site_id=site.pk ) ai_insight_filter = ( {"cycle_id": cycle.pk} if cycle is not None else {"cycle__kandang__site_id": site.pk} ) return { "exported_at": timezone.now().isoformat(), "from": date_from.isoformat(), "to": date_to.isoformat(), "cycle_id": cycle.pk if cycle else None, "site_id": site.pk, "page": page, "page_size": page_size, "sites": sites_tree, "data": { "kpis": series(KPI, KPISerializer), "manual_inputs": series(ManualInput, ManualInputSerializer), "feed_sacks": series(FeedSacks, FeedSacksSerializer), "chicken_countings": series(ChickenCounting, ChickenCountingSerializer), "chicken_weights": series(ChickenWeight, ChickenWeightSerializer), "ai_insights": series( AIInsight, AIInsightSerializer, override_filter=ai_insight_filter ), }, } def resolve_iot_export_params( *, site_id: str | None, kandang_id: str | None, flock_id: str | None, date_from: str | None, date_to: str | None, page: str | None, page_size: str | None, ) -> dict[str, Any]: if not site_id or not str(site_id).strip(): raise PusatExportError("site_id wajib diisi.") site_id = str(site_id).strip() try: site = Site.objects.get(pk=site_id) except Site.DoesNotExist as exc: raise PusatExportError("site_id tidak ditemukan.") from exc kandang: Kandang | None = None if kandang_id is not None and str(kandang_id).strip() != "": try: kandang = Kandang.objects.get(pk=int(kandang_id)) except (Kandang.DoesNotExist, TypeError, ValueError) as exc: raise PusatExportError("kandang_id tidak ditemukan.") from exc if kandang.site_id != site.pk: raise PusatExportError("kandang_id tidak termasuk site_id tersebut.") flock: Flock | None = None if flock_id is not None and str(flock_id).strip() != "": try: flock = Flock.objects.select_related("kandang").get(pk=int(flock_id)) except (Flock.DoesNotExist, TypeError, ValueError) as exc: raise PusatExportError("flock_id tidak ditemukan.") from exc if flock.kandang.site_id != site.pk: raise PusatExportError("flock_id tidak termasuk site_id tersebut.") if kandang is not None and flock.kandang_id != kandang.pk: raise PusatExportError("flock_id tidak termasuk kandang_id tersebut.") parsed_from = _parse_optional_date(date_from, "from") parsed_to = _parse_optional_date(date_to, "to") if parsed_from is None and parsed_to is None: parsed_from, parsed_to = _default_window() elif parsed_from is None or parsed_to is None: raise PusatExportError("from dan to harus diisi berpasangan, atau keduanya dikosongkan.") if parsed_to < parsed_from: raise PusatExportError("to harus pada atau setelah from.") page_n, page_size_n = _parse_page(page, page_size) return { "site": site, "kandang": kandang, "flock": flock, "date_from": parsed_from, "date_to": parsed_to, "page": page_n, "page_size": page_size_n, } def build_iot_export( *, site: Site, kandang: Kandang | None, flock: Flock | None, date_from: date, date_to: date, page: int, page_size: int, ) -> dict[str, Any]: kandangs_qs = Kandang.objects.filter(site=site).order_by("kandang_id") flocks_qs = Flock.objects.filter(kandang__site=site).select_related("kandang") if kandang is not None: kandangs_qs = kandangs_qs.filter(pk=kandang.pk) flocks_qs = flocks_qs.filter(kandang_id=kandang.pk) if flock is not None: kandangs_qs = kandangs_qs.filter(pk=flock.kandang_id) flocks_qs = flocks_qs.filter(pk=flock.pk) flocks_by_kandang: dict[int, list[Flock]] = {} for f in flocks_qs.order_by("flock_id"): flocks_by_kandang.setdefault(f.kandang_id, []).append(f) site_node = _site_payload(site) kandang_nodes = [] for k in kandangs_qs: node = _kandang_payload(k, include_feed_urls=False) node["flocks"] = [ { "flock_id": f.pk, "flock_name": f.flock_name, "created_at": f.created_at, "updated_at": f.updated_at, } for f in flocks_by_kandang.get(k.pk, []) ] kandang_nodes.append(node) site_node["kandangs"] = kandang_nodes iot_qs = ( IotPanel.objects.filter( flock__kandang__site_id=site.pk, date__gte=date_from, date__lte=date_to, ) .select_related("flock", "flock__kandang") .order_by("timestamp", "panel_id") ) if kandang is not None: iot_qs = iot_qs.filter(flock__kandang_id=kandang.pk) if flock is not None: iot_qs = iot_qs.filter(flock_id=flock.pk) iot_page = _serialize_page( iot_qs, IotPanelSerializer, page=page, page_size=page_size, site_id=site.pk ) for row in iot_page["results"]: # Serializer already exposes flock_id / kandang; ensure site_id present. row.setdefault("site_id", site.pk) return { "exported_at": timezone.now().isoformat(), "from": date_from.isoformat(), "to": date_to.isoformat(), "flock_id": flock.pk if flock else None, "kandang_id": kandang.pk if kandang else None, "site_id": site.pk, "page": page, "page_size": page_size, "sites": [site_node], "data": {"iot_panels": iot_page}, }