diff --git a/DOCKER.md b/DOCKER.md index 305056c..e2a9ba3 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -110,7 +110,7 @@ Important production values: | `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) | | `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation | | `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) | -| `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) | +| `SITE_API_KEY` | Optional fixed API key for scripts (hashed at rest) | Wagtail admin is proxied at **http://localhost/admin/** (through Nginx → Django). diff --git a/backend/.env.example b/backend/.env.example index 4d853fd..71ff71e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,8 +24,8 @@ API_PAGE_SIZE=50 SESSION_COOKIE_SECURE=False CSRF_COOKIE_SECURE=False -# Optional: create hashed ApiKey on seed/bootstrap (raw shown once in seed output) -BOOTSTRAP_API_KEY= +# Optional: fixed machine API key for pusat pull + scripts (hashed at rest). +SITE_API_KEY= # Production bootstrap (bootstrap_admin — no demo data) BOOTSTRAP_ADMIN_USER=admin @@ -36,8 +36,8 @@ BOOTSTRAP_STAFF_USER= BOOTSTRAP_STAFF_PASSWORD= # Pusat (executive) cycle-close + register channel -# Outbound register/notify uses these; inbound apply-close-decision requires -# PUSAT_API_KEY in X-API-Key. active_site_id is persisted on register. +# Outbound register/notify uses PUSAT_API_KEY; inbound apply-close-decision +# authenticates with SITE_API_KEY (X-API-Key). active_site_id is persisted on register. PUSAT_BASE_URL= PUSAT_API_KEY= # This site's public API root including /api/v1 (written to executive ActiveSite). diff --git a/backend/apps/farms/services/pusat_export.py b/backend/apps/farms/services/pusat_export.py new file mode 100644 index 0000000..959bdef --- /dev/null +++ b/backend/apps/farms/services/pusat_export.py @@ -0,0 +1,394 @@ +"""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}, + } diff --git a/backend/apps/farms/services/pusat_register.py b/backend/apps/farms/services/pusat_register.py index e06b3ce..487b27a 100644 --- a/backend/apps/farms/services/pusat_register.py +++ b/backend/apps/farms/services/pusat_register.py @@ -45,13 +45,16 @@ def register_site_to_pusat( Change code after register: allow_code_change=True (requires existing registration). """ base = (settings.PUSAT_BASE_URL or "").rstrip("/") - api_key = (settings.PUSAT_API_KEY or "").strip() + pusat_key = (settings.PUSAT_API_KEY or "").strip() + site_key = (settings.SITE_API_KEY or "").strip() site_api = (settings.SITE_API_BASE_URL or "").rstrip("/") if not base: raise PusatRegisterError("PUSAT_BASE_URL belum dikonfigurasi.") - if not api_key: + if not pusat_key: raise PusatRegisterError("PUSAT_API_KEY belum dikonfigurasi.") + if not site_key: + raise PusatRegisterError("SITE_API_KEY belum dikonfigurasi.") if not site_api: raise PusatRegisterError("SITE_API_BASE_URL belum dikonfigurasi.") @@ -75,9 +78,9 @@ def register_site_to_pusat( "site_id": site.pk, "name": site.site_name, "api_base_url": site_api, - "api_key": api_key, + "api_key": site_key, } - headers = {"X-API-Key": api_key, "Content-Type": "application/json"} + headers = {"X-API-Key": pusat_key, "Content-Type": "application/json"} try: with httpx.Client(timeout=30) as client: diff --git a/backend/apps/farms/tests.py b/backend/apps/farms/tests.py index f1571f8..d3ec34e 100644 --- a/backend/apps/farms/tests.py +++ b/backend/apps/farms/tests.py @@ -190,7 +190,7 @@ class FarmAPITests(TestCase): self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE) def test_apply_close_decision_approve(self): - from django.test import override_settings + from apps.accounts.models import ApiKey start = date(2026, 8, 1) end = start + timedelta(days=34) @@ -204,13 +204,18 @@ class FarmAPITests(TestCase): status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=end, ) - with override_settings(PUSAT_API_KEY="pusat-test-key"): - response = self.client.post( - f"/api/v1/cycles/{cycle.pk}/apply-close-decision/", - {"decision": "approve", "end_date": end.isoformat()}, - format="json", - HTTP_X_API_KEY="pusat-test-key", - ) + site_key = "site-close-callback-key" + api_key, _ = ApiKey.generate(self.user, "site") + api_key.prefix = site_key[:8] + api_key.key_hash = ApiKey.hash_key(site_key) + api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) + + response = self.client.post( + f"/api/v1/cycles/{cycle.pk}/apply-close-decision/", + {"decision": "approve", "end_date": end.isoformat()}, + format="json", + HTTP_X_API_KEY=site_key, + ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED) self.assertEqual(response.data["end_date"], end.isoformat()) diff --git a/backend/apps/farms/tests_pusat_export.py b/backend/apps/farms/tests_pusat_export.py new file mode 100644 index 0000000..bf4ca1a --- /dev/null +++ b/backend/apps/farms/tests_pusat_export.py @@ -0,0 +1,218 @@ +from datetime import date, timedelta +from unittest.mock import patch + +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.accounts.models import ApiKey, User +from apps.farms.models import Cycle, Flock, Kandang, Site +from apps.operations.models import AIInsight, ChickenCounting, FeedSacks, IotPanel + + +SITE_KEY = "site-export-test-key" + + +@override_settings(DASHBOARD_PUBLISH_HOUR=17, TIME_ZONE="Asia/Jakarta") +class PusatExportTests(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + user_name="export-admin", + password="x", + status=User.STATUS_SUPERADMIN, + ) + api_key, _ = ApiKey.generate(self.admin, "site") + api_key.prefix = SITE_KEY[:8] + api_key.key_hash = ApiKey.hash_key(SITE_KEY) + api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) + + self.site = Site.objects.create(site_name="ExportSite", user=self.admin) + self.other_site = Site.objects.create(site_name="OtherSite", user=self.admin) + self.kandang = Kandang.objects.create(kandang_name="K1", site=self.site) + self.other_kandang = Kandang.objects.create(kandang_name="K2", site=self.other_site) + self.flock = Flock.objects.create(flock_name="F1", kandang=self.kandang) + self.other_flock = Flock.objects.create(flock_name="F2", kandang=self.other_kandang) + + today = timezone.localdate() + self.cycle = Cycle.objects.create( + kandang=self.kandang, + start_date=today - timedelta(days=10), + doc_in_weight=40, + doc_in_count=1000, + status=Cycle.STATUS_ACTIVE, + ) + self.other_cycle = Cycle.objects.create( + kandang=self.other_kandang, + start_date=today - timedelta(days=10), + doc_in_weight=40, + doc_in_count=1000, + status=Cycle.STATUS_ACTIVE, + ) + + # "Today" rows would be hidden by publish cutoff on normal list APIs before 17:00. + FeedSacks.objects.create(cycle=self.cycle, date=today, in_today=1) + FeedSacks.objects.create(cycle=self.other_cycle, date=today, in_today=2) + ChickenCounting.objects.create( + cycle=self.cycle, date=today, total_count=100, mortality_count=1 + ) + AIInsight.objects.create( + cycle=self.cycle, + date=today, + insight_text="ok", + alert="none", + section="fcr", + session="morning", + ) + IotPanel.objects.create( + flock=self.flock, + date=today, + timestamp=timezone.now(), + wind_speed=1.0, + humidity=50.0, + water_total=10.0, + average_temperature=28.0, + experience_temperature=27.0, + ) + IotPanel.objects.create( + flock=self.other_flock, + date=today, + timestamp=timezone.now(), + wind_speed=1.0, + humidity=50.0, + water_total=10.0, + average_temperature=28.0, + experience_temperature=27.0, + ) + + def _auth(self): + self.client.credentials(HTTP_X_API_KEY=SITE_KEY) + + def test_ops_requires_auth(self): + response = self.client.get("/api/v1/pusat/export/", {"site_id": self.site.pk}) + self.assertEqual(response.status_code, 401) + + def test_ops_requires_site_id(self): + self._auth() + response = self.client.get("/api/v1/pusat/export/") + self.assertEqual(response.status_code, 400) + + def test_ops_unknown_site(self): + self._auth() + response = self.client.get("/api/v1/pusat/export/", {"site_id": "missing-9999"}) + self.assertEqual(response.status_code, 400) + + def test_ops_cycle_site_mismatch(self): + self._auth() + response = self.client.get( + "/api/v1/pusat/export/", + {"site_id": self.site.pk, "cycle_id": self.other_cycle.pk}, + ) + self.assertEqual(response.status_code, 400) + + def test_ops_export_scopes_and_includes_today(self): + self._auth() + today = timezone.localdate().isoformat() + response = self.client.get( + "/api/v1/pusat/export/", + {"site_id": self.site.pk, "from": today, "to": today}, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["site_id"], self.site.pk) + self.assertEqual(len(response.data["sites"]), 1) + self.assertNotIn("flocks", response.data["sites"][0]["kandangs"][0]) + self.assertNotIn("iot_panels", response.data["data"]) + self.assertEqual(response.data["data"]["feed_sacks"]["count"], 1) + self.assertEqual(response.data["data"]["chicken_countings"]["count"], 1) + self.assertEqual(response.data["data"]["ai_insights"]["count"], 1) + self.assertEqual( + response.data["data"]["feed_sacks"]["results"][0]["site_id"], self.site.pk + ) + + def test_ops_cycle_id_defaults_to_cycle_span(self): + self._auth() + closed = Cycle.objects.create( + kandang=self.kandang, + start_date=date(2026, 1, 1), + end_date=date(2026, 2, 10), + doc_in_weight=40, + doc_in_count=1000, + status=Cycle.STATUS_CLOSED, + ) + FeedSacks.objects.create(cycle=closed, date=date(2026, 1, 15), in_today=3) + response = self.client.get( + "/api/v1/pusat/export/", + {"site_id": self.site.pk, "cycle_id": closed.pk}, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["from"], "2026-01-01") + self.assertEqual(response.data["to"], "2026-02-10") + self.assertEqual(response.data["data"]["feed_sacks"]["count"], 1) + + def test_ops_export_includes_initial_balance_iot(self): + self._auth() + today = timezone.localdate() + self.cycle.feed_initial_balance = 120 + self.cycle.feed_initial_balance_date = today + self.cycle.save( + update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"] + ) + with patch( + "apps.farms.services.pusat_export.pull_initial_balance_iot", + return_value=(115, None), + ) as mock_pull: + response = self.client.get( + "/api/v1/pusat/export/", + {"site_id": self.site.pk, "cycle_id": self.cycle.pk}, + ) + self.assertEqual(response.status_code, 200, response.data) + cycle_row = response.data["sites"][0]["kandangs"][0]["cycles"][0] + self.assertEqual(cycle_row["feed_initial_balance"], 120) + self.assertEqual(cycle_row["feed_initial_balance_date"], today.isoformat()) + self.assertEqual(cycle_row["feed_initial_balance_iot"], 115) + self.assertEqual(cycle_row["feed_initial_balance_accuracy"], 95.8) + self.assertIsNone(cycle_row["feed_initial_balance_sync_error"]) + mock_pull.assert_called_once_with(self.cycle, today) + + def test_ops_export_skips_iot_pull_without_balance_date(self): + self._auth() + with patch( + "apps.farms.services.pusat_export.pull_initial_balance_iot", + return_value=(99, None), + ) as mock_pull: + response = self.client.get( + "/api/v1/pusat/export/", + {"site_id": self.site.pk, "cycle_id": self.cycle.pk}, + ) + self.assertEqual(response.status_code, 200, response.data) + cycle_row = response.data["sites"][0]["kandangs"][0]["cycles"][0] + self.assertIsNone(cycle_row["feed_initial_balance_iot"]) + self.assertIsNone(cycle_row["feed_initial_balance_accuracy"]) + self.assertIsNone(cycle_row["feed_initial_balance_sync_error"]) + mock_pull.assert_not_called() + + + def test_iot_flock_site_mismatch(self): + self._auth() + response = self.client.get( + "/api/v1/pusat/export/iot/", + {"site_id": self.site.pk, "flock_id": self.other_flock.pk}, + ) + self.assertEqual(response.status_code, 400) + + def test_iot_export_structure_and_panels(self): + self._auth() + today = timezone.localdate().isoformat() + response = self.client.get( + "/api/v1/pusat/export/iot/", + {"site_id": self.site.pk, "from": today, "to": today}, + ) + self.assertEqual(response.status_code, 200, response.data) + site_node = response.data["sites"][0] + self.assertEqual(site_node["site_id"], self.site.pk) + self.assertIn("flocks", site_node["kandangs"][0]) + self.assertNotIn("cycles", site_node["kandangs"][0]) + self.assertEqual(response.data["data"]["iot_panels"]["count"], 1) + self.assertEqual( + response.data["data"]["iot_panels"]["results"][0]["site_id"], self.site.pk + ) diff --git a/backend/apps/farms/tests_pusat_register.py b/backend/apps/farms/tests_pusat_register.py index ab4b6aa..1184692 100644 --- a/backend/apps/farms/tests_pusat_register.py +++ b/backend/apps/farms/tests_pusat_register.py @@ -10,6 +10,7 @@ from apps.farms.models import Site @override_settings( PUSAT_BASE_URL="http://pusat.example/api/v1", PUSAT_API_KEY="shared-key", + SITE_API_KEY="site-machine-key", SITE_API_BASE_URL="https://site.example/api/v1", ) class PusatRegisterTests(TestCase): @@ -51,6 +52,7 @@ class PusatRegisterTests(TestCase): self.assertEqual(kwargs["json"]["api_base_url"], "https://site.example/api/v1") self.assertEqual(kwargs["json"]["name"], "Sukawarna") self.assertEqual(kwargs["json"]["site_id"], self.site.pk) + self.assertEqual(kwargs["json"]["api_key"], "site-machine-key") self.assertEqual(kwargs["headers"]["X-API-Key"], "shared-key") def test_cannot_register_when_already_registered(self): diff --git a/backend/apps/farms/urls.py b/backend/apps/farms/urls.py index 5f593d7..6be4c7c 100644 --- a/backend/apps/farms/urls.py +++ b/backend/apps/farms/urls.py @@ -5,6 +5,8 @@ from apps.farms.views import ( CycleViewSet, FlockViewSet, KandangViewSet, + PusatIotExportView, + PusatOpsExportView, SiteViewSet, pusat_change_code, pusat_register, @@ -21,5 +23,7 @@ urlpatterns = [ path("pusat/register/", pusat_register, name="pusat-register"), path("pusat/change-code/", pusat_change_code, name="pusat-change-code"), path("pusat/registration/", pusat_registration_status, name="pusat-registration-status"), + path("pusat/export/", PusatOpsExportView.as_view(), name="pusat-export-ops"), + path("pusat/export/iot/", PusatIotExportView.as_view(), name="pusat-export-iot"), *router.urls, ] diff --git a/backend/apps/farms/views.py b/backend/apps/farms/views.py index c20bb6b..a3df76c 100644 --- a/backend/apps/farms/views.py +++ b/backend/apps/farms/views.py @@ -1,16 +1,22 @@ -from django.conf import settings from django.core.exceptions import ValidationError from django.utils.dateparse import parse_date from rest_framework import permissions, status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response +from rest_framework.views import APIView -import secrets - +from apps.accounts.authentication import ApiKeyAuthentication from apps.accounts.models import User from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer from apps.farms.services.pusat_close import PusatCloseError, notify_pusat_close_request +from apps.farms.services.pusat_export import ( + PusatExportError, + build_iot_export, + build_ops_export, + resolve_iot_export_params, + resolve_ops_export_params, +) from apps.farms.services.pusat_register import PusatRegisterError, register_site_to_pusat from apps.operations.services.feed_sacks import ( cleanup_opening_balance_feed_rows, @@ -30,23 +36,19 @@ def _validation_detail(exc: ValidationError) -> str | dict: return str(exc.message) if hasattr(exc, "message") else str(exc) -def _pusat_api_key_from_request(request) -> str | None: - return request.headers.get("X-API-Key") or request.META.get("HTTP_X_API_KEY") +class IsApiKeyAuthenticated(permissions.BasePermission): + """Require authentication via hashed site ApiKey (reject session-only users).""" - -class IsPusatCloseCallback(permissions.BasePermission): - """Allow only the shared pusat callback key (not session users).""" - - message = "Pusat API key required." + message = "Site API key required." def has_permission(self, request, view): - expected = (getattr(settings, "PUSAT_API_KEY", "") or "").strip() - if not expected: - return False - provided = (_pusat_api_key_from_request(request) or "").strip() - if not provided: - return False - return secrets.compare_digest(provided, expected) + from apps.accounts.models import ApiKey + + return bool( + request.user + and request.user.is_authenticated + and isinstance(getattr(request, "auth", None), ApiKey) + ) def _revert_pending_close(cycle: Cycle) -> None: @@ -286,11 +288,11 @@ class CycleViewSet(viewsets.ModelViewSet): detail=True, methods=["post"], url_path="apply-close-decision", - authentication_classes=[], - permission_classes=[IsPusatCloseCallback], + authentication_classes=[ApiKeyAuthentication], + permission_classes=[IsApiKeyAuthenticated], ) def apply_close_decision(self, request, pk=None): - """Machine endpoint: pusat pushes approve/reject via shared PUSAT_API_KEY.""" + """Machine endpoint: pusat pushes approve/reject with the site SITE_API_KEY.""" cycle = self.get_object() decision = str(request.data.get("decision", "")).strip().lower() if decision not in {"approve", "reject"}: @@ -434,3 +436,48 @@ class CycleViewSet(viewsets.ModelViewSet): "sync_error": sync_error, } ) + + +class PusatOpsExportView(APIView): + """Machine pull: sites → kandangs → cycles + ops time-series (no publish cutoff).""" + + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + params = request.query_params + try: + resolved = resolve_ops_export_params( + site_id=params.get("site_id"), + cycle_id=params.get("cycle_id"), + date_from=params.get("from"), + date_to=params.get("to"), + page=params.get("page"), + page_size=params.get("page_size"), + ) + payload = build_ops_export(**resolved) + except PusatExportError as exc: + return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST) + return Response(payload) + + +class PusatIotExportView(APIView): + """Machine pull: sites → kandangs → flocks + iot_panels (no publish cutoff).""" + + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + params = request.query_params + try: + resolved = resolve_iot_export_params( + site_id=params.get("site_id"), + kandang_id=params.get("kandang_id"), + flock_id=params.get("flock_id"), + date_from=params.get("from"), + date_to=params.get("to"), + page=params.get("page"), + page_size=params.get("page_size"), + ) + payload = build_iot_export(**resolved) + except PusatExportError as exc: + return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST) + return Response(payload) diff --git a/backend/apps/jobs/management/commands/bootstrap_admin.py b/backend/apps/jobs/management/commands/bootstrap_admin.py index b83301b..9f628c4 100644 --- a/backend/apps/jobs/management/commands/bootstrap_admin.py +++ b/backend/apps/jobs/management/commands/bootstrap_admin.py @@ -109,13 +109,13 @@ class Command(BaseCommand): stdout=self.stdout, ) - bootstrap_key = settings.BOOTSTRAP_API_KEY + bootstrap_key = settings.SITE_API_KEY if bootstrap_key: - api_key, _ = ApiKey.generate(admin, "bootstrap") + api_key, _ = ApiKey.generate(admin, "site") api_key.prefix = bootstrap_key[:8] api_key.key_hash = ApiKey.hash_key(bootstrap_key) api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) - self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})") + self.stdout.write(f"Site API key installed (prefix={api_key.prefix})") accounts = admin_user_name if staff_user_name: diff --git a/backend/apps/jobs/management/commands/seed_demo.py b/backend/apps/jobs/management/commands/seed_demo.py index 9d07f5b..ce48bcf 100644 --- a/backend/apps/jobs/management/commands/seed_demo.py +++ b/backend/apps/jobs/management/commands/seed_demo.py @@ -35,14 +35,14 @@ class Command(BaseCommand): user.save(update_fields=["display_name", "updated_at"]) raw_key = None - bootstrap = settings.BOOTSTRAP_API_KEY + bootstrap = settings.SITE_API_KEY if bootstrap: - api_key, raw_key = ApiKey.generate(user, "bootstrap") + api_key, raw_key = ApiKey.generate(user, "site") api_key.prefix = bootstrap[:8] api_key.key_hash = ApiKey.hash_key(bootstrap) api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) raw_key = bootstrap - self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})") + self.stdout.write(f"Site API key installed (prefix={api_key.prefix})") elif not ApiKey.objects.filter(user=user).exists(): api_key, raw_key = ApiKey.generate(user, "seed") self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}")) diff --git a/backend/apps/operations/migrations/0011_aiinsight_unique_cycle_date_section_session.py b/backend/apps/operations/migrations/0011_aiinsight_unique_cycle_date_section_session.py new file mode 100644 index 0000000..08cc844 --- /dev/null +++ b/backend/apps/operations/migrations/0011_aiinsight_unique_cycle_date_section_session.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("operations", "0010_delete_reportsnapshot"), + ] + + operations = [ + migrations.AddConstraint( + model_name="aiinsight", + constraint=models.UniqueConstraint( + fields=("cycle", "date", "section", "session"), + name="uniq_ai_insight_cycle_date_section_session", + ), + ), + ] diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index b3362e2..05df92f 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -39,6 +39,12 @@ class AIInsight(models.Model): class Meta: db_table = "ai_insight" ordering = ["-date"] + constraints = [ + models.UniqueConstraint( + fields=["cycle", "date", "section", "session"], + name="uniq_ai_insight_cycle_date_section_session", + ) + ] class FeedSacks(models.Model): diff --git a/backend/config/settings.py b/backend/config/settings.py index 69c42a2..acbefea 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -168,13 +168,14 @@ SPECTACULAR_SETTINGS = { "VERSION": "1.0.0", } -BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or "" - BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin" BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or "" BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or "" BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or "" +# Machine API key for this farm deployment (pusat pull + register body api_key). +SITE_API_KEY = (env("SITE_API_KEY", "") or "").strip() + KARUNG_WEB_ADMIN_BASE_URL = ( env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or "" ).rstrip("/") diff --git a/backend/database/erd_schema.sql b/backend/database/erd_schema.sql index 7b31b0f..55cefb4 100644 --- a/backend/database/erd_schema.sql +++ b/backend/database/erd_schema.sql @@ -195,6 +195,8 @@ CREATE TABLE IF NOT EXISTS ai_insight ( CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id); CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cycle_date_section_session + ON ai_insight (cycle_id, date, section, session); -- ----------------------------------------------------------------------------- -- 8. Feed Sacks diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 43f3afd..be6ff56 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -22,6 +22,7 @@ import { import { throughVisibleDate } from '../utils/visibility.ts'; import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'; import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; +import DashboardCloseCycleControls from './dashboard/DashboardCloseCycleControls.tsx'; import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx'; import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx'; import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx'; @@ -229,19 +230,22 @@ const Dashboard: React.FC = ({ setView }) => { title="Dashboard" subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : cycleStatus === 'pending_close' ? 'menunggu GM' : 'selesai'})`} /> - { - setReportType('daily'); - setReportOpen(true); - }} - onOpenEndCycle={() => { - setReportType('end_cycle'); - setReportOpen(true); - }} - /> +
+ + { + setReportType('daily'); + setReportOpen(true); + }} + onOpenEndCycle={() => { + setReportType('end_cycle'); + setReportOpen(true); + }} + /> +
{error && (
diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 8026597..94a65f5 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -59,7 +59,6 @@ const SettingsPage: React.FC = () => { const [flockName, setFlockName] = useState(''); const [editingCycleId, setEditingCycleId] = useState(null); const [startDate, setStartDate] = useState(defaultCycleForm().startDate); - const [proposedCloseDate, setProposedCloseDate] = useState(todayIso()); const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount); const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight); const [registerCode, setRegisterCode] = useState(''); @@ -559,8 +558,8 @@ const SettingsPage: React.FC = () => {

)}

- Tanggal selesai tidak diisi di sini. Ajukan tutup siklus setelah produksi selesai; GM - yang menyetujui end date. + Tanggal selesai tidak diisi di sini. Ajukan tutup siklus dari Dashboard setelah + produksi selesai; GM yang menyetujui end date.

+ ); +}; + +export default DashboardCloseCycleControls; diff --git a/components/dashboard/__tests__/DashboardCloseCycleControls.test.tsx b/components/dashboard/__tests__/DashboardCloseCycleControls.test.tsx new file mode 100644 index 0000000..9030f12 --- /dev/null +++ b/components/dashboard/__tests__/DashboardCloseCycleControls.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import DashboardCloseCycleControls from '../DashboardCloseCycleControls.tsx'; + +const { + requestCloseMock, + cancelCloseRequestMock, + reloadMock, + farmState, + authState, +} = vi.hoisted(() => ({ + requestCloseMock: vi.fn().mockResolvedValue({}), + cancelCloseRequestMock: vi.fn().mockResolvedValue({}), + reloadMock: vi.fn().mockResolvedValue(undefined), + farmState: { + selectedCycle: { + id: 5, + start_date: '2026-09-01', + end_date: null, + status: 'active', + proposed_end_date: null, + visible_through_date: '2026-09-10', + } as Record | null, + }, + authState: { + user: { + id: 1, + user_name: 'staff', + display_name: 'Staff', + status: 'active', + }, + }, +})); + +vi.mock('../../../services/apiClient.ts', () => ({ + api: { + cycles: { + requestClose: requestCloseMock, + cancelCloseRequest: cancelCloseRequestMock, + }, + }, + errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'), +})); + +vi.mock('../../../context/AuthContext.tsx', () => ({ + useAuth: () => authState, +})); + +vi.mock('../../../context/FarmContext.tsx', () => ({ + useFarm: () => ({ + selectedCycle: farmState.selectedCycle, + reload: reloadMock, + }), +})); + +describe('DashboardCloseCycleControls', () => { + beforeEach(() => { + requestCloseMock.mockClear(); + cancelCloseRequestMock.mockClear(); + reloadMock.mockClear(); + authState.user.status = 'active'; + farmState.selectedCycle = { + id: 5, + start_date: '2026-09-01', + end_date: null, + status: 'active', + proposed_end_date: null, + visible_through_date: '2026-09-10', + }; + }); + + it('shows date + Ajukan tutup for active cycle and requires confirm before API', async () => { + render(); + expect(screen.getByRole('button', { name: /Ajukan tutup/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Ajukan tutup/i })); + expect(requestCloseMock).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Ya, ajukan tutup/i })); + await waitFor(() => { + expect(requestCloseMock).toHaveBeenCalledWith(5, { + proposed_end_date: expect.any(String), + }); + }); + expect(reloadMock).toHaveBeenCalled(); + }); + + it('shows cancel control when pending_close', () => { + farmState.selectedCycle = { + id: 5, + start_date: '2026-09-01', + end_date: null, + status: 'pending_close', + proposed_end_date: '2026-09-15', + visible_through_date: '2026-09-10', + }; + render(); + expect(screen.queryByRole('button', { name: /Ajukan tutup/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Batalkan permintaan/i })).toBeInTheDocument(); + }); + + it('hides controls for non-active users', () => { + authState.user.status = 'superadmin'; + render(); + expect(screen.queryByRole('button', { name: /Ajukan tutup/i })).not.toBeInTheDocument(); + }); + + it('shows API failure in an alert popup', async () => { + requestCloseMock.mockRejectedValueOnce( + new Error('gagal kirim ke pusat — coba lagi (400: {"detail":"Cycle is already closed."})') + ); + render(); + fireEvent.click(screen.getByRole('button', { name: /Ajukan tutup/i })); + fireEvent.click(screen.getByRole('button', { name: /Ya, ajukan tutup/i })); + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByText(/Cycle is already closed/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^Tutup$/i })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/components/ui/Feedback.tsx b/components/ui/Feedback.tsx index f71ac3a..10a8bfd 100644 --- a/components/ui/Feedback.tsx +++ b/components/ui/Feedback.tsx @@ -124,3 +124,73 @@ export const ConfirmDialog: React.FC = ({ ); }; + +type AlertDialogProps = { + open: boolean; + title: string; + message: React.ReactNode; + dismissLabel?: string; + kind?: 'error' | 'info' | 'success'; + onDismiss: () => void; +}; + +const alertIconStyles = { + error: 'bg-red-600', + info: 'bg-sky-600', + success: 'bg-green-600', +}; + +const alertIconClass = { + error: 'fa-triangle-exclamation', + info: 'fa-circle-info', + success: 'fa-circle-check', +}; + +export const AlertDialog: React.FC = ({ + open, + title, + message, + dismissLabel = 'Tutup', + kind = 'error', + onDismiss, +}) => { + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > +
+
+ +
+
+

+ {title} +

+
{message}
+
+
+
+ +
+
+
+ ); +}; diff --git a/docker-compose.yml b/docker-compose.yml index 0ac9c27..2ea0e7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,7 @@ services: CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30} CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-true} CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-true} - BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-test-api-key} + SITE_API_KEY: ${SITE_API_KEY:-test-api-key} BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n} BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw} diff --git a/docker/.env.example b/docker/.env.example index 10d7f84..f4b0f02 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -21,8 +21,8 @@ BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n BOOTSTRAP_STAFF_USER=staff BOOTSTRAP_STAFF_PASSWORD=change_me -# Optional: fixed API key for scripts (hashed at rest) -BOOTSTRAP_API_KEY= +# Optional: fixed API key for pusat pull + scripts (hashed at rest) +SITE_API_KEY= # Pusat (executive) — register + cycle-close PUSAT_BASE_URL= diff --git a/kpi-results.json b/kpi-results.json new file mode 100644 index 0000000..80d23b7 --- /dev/null +++ b/kpi-results.json @@ -0,0 +1,263 @@ +[ + { + "id": 1, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-02", + "age": 0, + "mortality": 10, + "mortality_total": 10, + "feed": 7, + "feed_total": 7, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49990, + "chicken_life_percentage": 99.98, + "iot_weight": 35, + "average_harvest_day": 0, + "average_harvest_weight": 35, + "fcr": 0.20004000800160032, + "eef": 0, + "cc": 1, + "cw": 1, + "fs": 6, + "manual": 5, + "created_at": "2026-09-09T08:04:41.824756+00:00", + "updated_at": "2026-09-10T07:56:44.28364+00:00" + }, + { + "id": 2, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-03", + "age": 1, + "mortality": 29, + "mortality_total": 39, + "feed": 4, + "feed_total": 11, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49961, + "chicken_life_percentage": 99.922, + "iot_weight": 45.3, + "average_harvest_day": 1, + "average_harvest_weight": 45.3, + "fcr": 0.24301515888795028, + "eef": 1862.6272619014144, + "cc": 2, + "cw": 2, + "fs": 5, + "manual": 6, + "created_at": "2026-09-09T08:04:41.83067+00:00", + "updated_at": "2026-09-10T07:56:44.286462+00:00" + }, + { + "id": 3, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-04", + "age": 2, + "mortality": 10, + "mortality_total": 49, + "feed": 5, + "feed_total": 16, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 58.2, + "average_harvest_day": 2, + "average_harvest_weight": 58.2, + "fcr": 0.2751837694411313, + "eef": 1056.438832095405, + "cc": 3, + "cw": 3, + "fs": 39, + "manual": 7, + "created_at": "2026-09-09T08:21:26.912315+00:00", + "updated_at": "2026-09-10T07:56:44.288276+00:00" + }, + { + "id": 4, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-05", + "age": 3, + "mortality": 0, + "mortality_total": 49, + "feed": 0, + "feed_total": 16, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 74.3, + "average_harvest_day": 3, + "average_harvest_weight": 74.3, + "fcr": 0.2155544465878041, + "eef": 1147.8489878080409, + "cc": 4, + "cw": 4, + "fs": 40, + "manual": 8, + "created_at": "2026-09-09T08:24:44.096872+00:00", + "updated_at": "2026-09-10T07:56:44.290188+00:00" + }, + { + "id": 5, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-06", + "age": 4, + "mortality": 0, + "mortality_total": 49, + "feed": 0, + "feed_total": 16, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 93.7, + "average_harvest_day": 4, + "average_harvest_weight": 93.7, + "fcr": 0.17092524419929395, + "eef": 1369.1390999397308, + "cc": 5, + "cw": 5, + "fs": 42, + "manual": 9, + "created_at": "2026-09-09T09:47:27.141173+00:00", + "updated_at": "2026-09-10T07:56:44.292079+00:00" + }, + { + "id": 6, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-07", + "age": 5, + "mortality": 0, + "mortality_total": 49, + "feed": 0, + "feed_total": 16, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 116.3, + "average_harvest_day": 5, + "average_harvest_weight": 116.3, + "fcr": 0.13771019244603477, + "eef": 1687.3990797090848, + "cc": 6, + "cw": 6, + "fs": 43, + "manual": 10, + "created_at": "2026-09-09T09:47:27.145444+00:00", + "updated_at": "2026-09-10T07:56:44.293955+00:00" + }, + { + "id": 7, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-08", + "age": 6, + "mortality": 0, + "mortality_total": 49, + "feed": 0, + "feed_total": 16, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 99.5, + "average_harvest_day": 6, + "average_harvest_weight": 99.5, + "fcr": 0.16096176262787784, + "eef": 1029.2557310625107, + "cc": 7, + "cw": 7, + "fs": 44, + "manual": 11, + "created_at": "2026-09-10T03:52:36.73727+00:00", + "updated_at": "2026-09-10T07:56:44.295709+00:00" + }, + { + "id": 8, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-09", + "age": 7, + "mortality": 0, + "mortality_total": 49, + "feed": 0, + "feed_total": 16, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 119.6, + "average_harvest_day": 7, + "average_harvest_weight": 119.6, + "fcr": 0.13391049650061743, + "eef": 1274.655141438863, + "cc": 8, + "cw": 8, + "fs": 41, + "manual": 12, + "created_at": "2026-09-10T03:52:36.742558+00:00", + "updated_at": "2026-09-10T07:56:44.297528+00:00" + }, + { + "id": 9, + "cycle": 5, + "kandang": 1, + "flock_id": 6, + "date": "2026-09-10", + "age": 8, + "mortality": 0, + "mortality_total": 49, + "feed": 10, + "feed_total": 26, + "harvest": 0, + "harvest_total": 0, + "harvest_weight": 0, + "harvest_weight_total": 0, + "chicken_life": 49951, + "chicken_life_percentage": 99.902, + "iot_weight": 142.1, + "average_harvest_day": 8, + "average_harvest_weight": 142.1, + "fcr": 0.18314922586133003, + "eef": 968.8871283264695, + "cc": 9, + "cw": 9, + "fs": 45, + "manual": 13, + "created_at": "2026-09-10T07:56:44.299685+00:00", + "updated_at": "2026-09-10T07:56:44.299691+00:00" + } +]