395 lines
13 KiB
Python
395 lines
13 KiB
Python
"""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")):
|
|
qs = (
|
|
model.objects.filter(**series_filter)
|
|
.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
|
|
)
|
|
|
|
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),
|
|
},
|
|
}
|
|
|
|
|
|
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},
|
|
}
|