From 20d04acb3e3df9fc1efe03f4975775290dc6b5dc Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Fri, 18 Sep 2026 16:06:41 +0700 Subject: [PATCH] update api configuration for panel iot and adjust ai insight --- backend/.env.example | 5 +- .../migrations/0020_flock_iot_api_url.py | 15 ++ backend/apps/farms/models.py | 1 + backend/apps/farms/serializers.py | 5 +- backend/apps/farms/tests_flock_iot_url.py | 96 ++++++++ backend/apps/operations/services/iot_api.py | 85 ++++--- backend/apps/operations/tests_iot_sync.py | 159 +++++++++++++ backend/apps/operations/views.py | 36 ++- backend/config/settings.py | 4 +- components/IotPanelPage.tsx | 48 +++- components/SettingsPage.tsx | 77 +++++-- components/__tests__/SettingsPage.test.tsx | 44 +++- components/counting/DataManagementPage.tsx | 33 ++- components/shared/PageAiInsight.tsx | 218 ++++++++++-------- docker-compose.yml | 4 - docker/.env.example | 5 +- services/apiClient.ts | 4 +- types/api.ts | 6 +- utils/__tests__/countingInsightScope.test.ts | 80 +++++++ utils/insightContextBuilders.ts | 66 ++++-- 20 files changed, 814 insertions(+), 177 deletions(-) create mode 100644 backend/apps/farms/migrations/0020_flock_iot_api_url.py create mode 100644 backend/apps/farms/tests_flock_iot_url.py create mode 100644 backend/apps/operations/tests_iot_sync.py create mode 100644 utils/__tests__/countingInsightScope.test.ts diff --git a/backend/.env.example b/backend/.env.example index f49b77d..0a65870 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -51,12 +51,11 @@ KARUNG_WEB_ADMIN_SYNC_ENABLED=false DASHBOARD_PUBLISH_HOUR=17 DASHBOARD_PUBLISH_MINUTE=0 -# IoT panel — real-time sync every 10 min (excluded from publish cutoff) +# IoT panel — real-time sync every 10 min (excluded from publish cutoff). +# Configure per-flock API URLs in Settings (Flock → API Panel IoT). IOT_SYNC_ENABLED=true -IOT_API_BASE_URL=https://dashboard.cpsp.id/api/iot/flocks IOT_SYNC_LOOKBACK_MINUTES=20 IOT_SYNC_MAX_PAGES=10 -IOT_FLOCK_ID_MAP={} # Chicken counting edge vision API (counting + mortality + weight) CHICKEN_COUNTING_EDGE_BASE_URL=http://192.168.192.106:8090 diff --git a/backend/apps/farms/migrations/0020_flock_iot_api_url.py b/backend/apps/farms/migrations/0020_flock_iot_api_url.py new file mode 100644 index 0000000..a907649 --- /dev/null +++ b/backend/apps/farms/migrations/0020_flock_iot_api_url.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farms", "0019_site_pusat_fields"), + ] + + operations = [ + migrations.AddField( + model_name="flock", + name="iot_api_url", + field=models.CharField(blank=True, default="", max_length=500), + ), + ] diff --git a/backend/apps/farms/models.py b/backend/apps/farms/models.py index 3ab6768..3acec71 100644 --- a/backend/apps/farms/models.py +++ b/backend/apps/farms/models.py @@ -80,6 +80,7 @@ class Kandang(models.Model): class Flock(models.Model): flock_id = models.BigAutoField(primary_key=True) flock_name = models.CharField(max_length=30) + iot_api_url = models.CharField(max_length=500, blank=True, default="") kandang = models.ForeignKey( Kandang, on_delete=models.CASCADE, diff --git a/backend/apps/farms/serializers.py b/backend/apps/farms/serializers.py index 7986bd4..e943de4 100644 --- a/backend/apps/farms/serializers.py +++ b/backend/apps/farms/serializers.py @@ -82,9 +82,12 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer): class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer): class Meta: model = Flock - fields = ["id", "flock_name", "kandang", "created_at", "updated_at"] + fields = ["id", "flock_name", "iot_api_url", "kandang", "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"] + def validate_iot_api_url(self, value): + return (value or "").strip() + class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer): kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all()) diff --git a/backend/apps/farms/tests_flock_iot_url.py b/backend/apps/farms/tests_flock_iot_url.py new file mode 100644 index 0000000..24e8879 --- /dev/null +++ b/backend/apps/farms/tests_flock_iot_url.py @@ -0,0 +1,96 @@ +from django.test import Client, TestCase + +from apps.accounts.models import User +from apps.farms.models import Flock, Kandang, Site + + +class FlockIotApiUrlTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(user_name="owner", password="secret") + self.admin = User.objects.create_user( + user_name="admin", + password="admin123", + status=User.STATUS_SUPERADMIN, + is_superuser=True, + ) + self.staff = User.objects.create_user( + user_name="staff", + password="staff123", + status=User.STATUS_ACTIVE, + is_staff=True, + ) + self.site = Site.objects.create(site_name="Site A", user=self.owner) + self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) + self.flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) + + def _csrf_headers(self, client: Client) -> dict[str, str]: + token = client.cookies["csrftoken"].value + return {"HTTP_X_CSRFTOKEN": token} + + def _login(self, client: Client, user_name: str, password: str) -> None: + client.get("/api/v1/auth/csrf/") + response = client.post( + "/api/v1/auth/login/", + {"user_name": user_name, "password": password}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 200, response.content) + + def test_superadmin_can_set_iot_api_url(self): + client = Client(enforce_csrf_checks=True) + self._login(client, "admin", "admin123") + url = "https://example.test/iot/lantai-1" + response = client.patch( + f"/api/v1/flocks/{self.flock.pk}/", + {"iot_api_url": f" {url} "}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["iot_api_url"], url) + self.flock.refresh_from_db() + self.assertEqual(self.flock.iot_api_url, url) + + def test_superadmin_can_clear_iot_api_url(self): + self.flock.iot_api_url = "https://example.test/iot/lantai-1" + self.flock.save(update_fields=["iot_api_url"]) + client = Client(enforce_csrf_checks=True) + self._login(client, "admin", "admin123") + response = client.patch( + f"/api/v1/flocks/{self.flock.pk}/", + {"iot_api_url": ""}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 200, response.content) + self.flock.refresh_from_db() + self.assertEqual(self.flock.iot_api_url, "") + + def test_staff_cannot_set_iot_api_url(self): + client = Client(enforce_csrf_checks=True) + self._login(client, "staff", "staff123") + response = client.patch( + f"/api/v1/flocks/{self.flock.pk}/", + {"iot_api_url": "https://example.test/iot/lantai-1"}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 403, response.content) + self.flock.refresh_from_db() + self.assertEqual(self.flock.iot_api_url, "") + + def test_create_flock_without_iot_api_url(self): + client = Client(enforce_csrf_checks=True) + self._login(client, "admin", "admin123") + response = client.post( + "/api/v1/flocks/", + {"flock_name": "Lantai 2", "kandang": self.kandang.pk}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 201, response.content) + data = response.json() + self.assertEqual(data.get("iot_api_url", ""), "") + flock = Flock.objects.get(pk=data["id"]) + self.assertEqual(flock.iot_api_url, "") diff --git a/backend/apps/operations/services/iot_api.py b/backend/apps/operations/services/iot_api.py index c9715e3..714cff1 100644 --- a/backend/apps/operations/services/iot_api.py +++ b/backend/apps/operations/services/iot_api.py @@ -164,12 +164,21 @@ class SyncSummary: class IotApiClient: - def __init__(self, base_url: str | None = None, timeout: float | None = None): - self.base_url = (base_url or settings.IOT_API_BASE_URL).rstrip("/") + "/" + def __init__(self, base_url: str, timeout: float | None = None): + self.base_url = (base_url or "").strip() + if not self.base_url: + raise IotApiError("IoT API URL is empty") self.timeout = timeout or settings.IOT_API_SYNC_TIMEOUT_SECONDS def fetch_page(self, page: int) -> dict[str, Any]: - url = f"{self.base_url}?page={page}" + from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + + parts = urlsplit(self.base_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["page"] = str(page) + url = urlunsplit( + (parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment) + ) try: with httpx.Client(timeout=self.timeout) as client: response = client.get(url) @@ -183,32 +192,19 @@ class IotApiClient: return response.json() -def sync_iot_panels( +def _sync_flock_pages( + flock: Flock, *, - lookback_minutes: int | None = None, - max_pages: int | None = None, + lookback_minutes: int, + max_pages: int, + cutoff, ) -> SyncSummary: - flock_map: dict[str, int] = getattr(settings, "IOT_FLOCK_ID_MAP", {}) or {} - if not flock_map: - return SyncSummary(upserted=0, skipped=0, pages=0) - - lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES - page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES - cutoff = timezone.now() - timedelta(minutes=lookback) - - django_flocks: dict[str, Flock] = {} - for external_id, django_id in flock_map.items(): - try: - django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id)) - except Flock.DoesNotExist: - continue - - client = IotApiClient() + client = IotApiClient(flock.iot_api_url) upserted = 0 skipped = 0 pages_read = 0 - for page in range(1, page_limit + 1): + for page in range(1, max_pages + 1): payload = client.fetch_page(page) pages_read += 1 results = payload.get("results") or [] @@ -217,12 +213,6 @@ def sync_iot_panels( page_has_recent = False for record in results: - external_flock_id = str(record.get("flock_id") or "") - flock = django_flocks.get(external_flock_id) - if flock is None: - skipped += 1 - continue - fetched_raw = record.get("fetched_at") payload_data = (record.get("payload") or {}).get("data") if not fetched_raw or not payload_data: @@ -262,3 +252,40 @@ def sync_iot_panels( break return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read) + + +def sync_iot_panels( + *, + flock_id: int | None = None, + lookback_minutes: int | None = None, + max_pages: int | None = None, +) -> SyncSummary: + lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES + page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES + cutoff = timezone.now() - timedelta(minutes=lookback) + + flocks = Flock.objects.select_related("kandang").exclude(iot_api_url="") + if flock_id is not None: + flocks = flocks.filter(pk=flock_id) + + flock_list = list(flocks) + if not flock_list: + return SyncSummary(upserted=0, skipped=0, pages=0) + + upserted = 0 + skipped = 0 + pages_read = 0 + for flock in flock_list: + if not (flock.iot_api_url or "").strip(): + continue + summary = _sync_flock_pages( + flock, + lookback_minutes=lookback, + max_pages=page_limit, + cutoff=cutoff, + ) + upserted += summary.upserted + skipped += summary.skipped + pages_read += summary.pages + + return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read) diff --git a/backend/apps/operations/tests_iot_sync.py b/backend/apps/operations/tests_iot_sync.py new file mode 100644 index 0000000..112851c --- /dev/null +++ b/backend/apps/operations/tests_iot_sync.py @@ -0,0 +1,159 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.test import Client, TestCase +from django.utils import timezone + +from apps.accounts.models import User +from apps.farms.models import Cycle, Flock, Kandang, Site +from apps.operations.models import IotPanel +from apps.operations.services.iot_api import IotApiClient, sync_iot_panels + + +SAMPLE_PAYLOAD = { + "results": [ + { + "flock_id": "other-external-id", + "fetched_at": timezone.now().isoformat().replace("+00:00", "Z"), + "payload": { + "data": { + "actualTemperature": 300, + "humidity": 700, + "wind": 5, + "water": 12, + "sensors": {}, + } + }, + } + ] +} + + +class SyncIotPanelsPerFlockTests(TestCase): + def setUp(self): + owner = User.objects.create_user(user_name="owner", password="secret") + self.site = Site.objects.create(site_name="Site A", user=owner) + self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) + self.flock_a = Flock.objects.create( + flock_name="Lantai 1", + kandang=self.kandang, + iot_api_url="https://example.test/iot/a?token=1", + ) + self.flock_b = Flock.objects.create( + flock_name="Lantai 2", + kandang=self.kandang, + iot_api_url="", + ) + today = timezone.localdate() + Cycle.objects.create( + kandang=self.kandang, + start_date=today - timedelta(days=5), + end_date=today + timedelta(days=30), + doc_in_weight=40, + doc_in_count=10000, + ) + + @patch.object(IotApiClient, "fetch_page") + def test_upserts_on_flock_with_url_and_ignores_upstream_flock_id(self, mock_fetch): + mock_fetch.return_value = SAMPLE_PAYLOAD + summary = sync_iot_panels(max_pages=1) + self.assertEqual(summary.upserted, 1) + self.assertEqual(IotPanel.objects.filter(flock=self.flock_a).count(), 1) + self.assertEqual(IotPanel.objects.filter(flock=self.flock_b).count(), 0) + mock_fetch.assert_called() + + @patch.object(IotApiClient, "fetch_page") + def test_blank_url_flock_never_fetched(self, mock_fetch): + mock_fetch.return_value = SAMPLE_PAYLOAD + summary = sync_iot_panels(flock_id=self.flock_b.pk, max_pages=1) + self.assertEqual(summary.upserted, 0) + self.assertEqual(summary.pages, 0) + mock_fetch.assert_not_called() + + @patch.object(IotApiClient, "fetch_page") + def test_flock_id_filter_only_syncs_that_flock(self, mock_fetch): + mock_fetch.return_value = SAMPLE_PAYLOAD + flock_c = Flock.objects.create( + flock_name="Lantai 3", + kandang=self.kandang, + iot_api_url="https://example.test/iot/c", + ) + summary = sync_iot_panels(flock_id=flock_c.pk, max_pages=1) + self.assertEqual(summary.upserted, 1) + self.assertEqual(IotPanel.objects.filter(flock=flock_c).count(), 1) + self.assertEqual(IotPanel.objects.filter(flock=self.flock_a).count(), 0) + + def test_fetch_page_merges_existing_query_string(self): + client = IotApiClient("https://example.test/iot/a?token=1") + with patch("apps.operations.services.iot_api.httpx.Client") as mock_client_cls: + mock_client = mock_client_cls.return_value.__enter__.return_value + mock_response = mock_client.get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + client.fetch_page(2) + called_url = mock_client.get.call_args.args[0] + self.assertIn("token=1", called_url) + self.assertIn("page=2", called_url) + + +class PanelIotSyncActionTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(user_name="owner", password="secret") + self.staff = User.objects.create_user( + user_name="staff", + password="staff123", + status=User.STATUS_ACTIVE, + is_staff=True, + ) + self.site = Site.objects.create(site_name="Site A", user=self.owner) + self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) + self.flock = Flock.objects.create( + flock_name="Lantai 1", + kandang=self.kandang, + iot_api_url="https://example.test/iot/a", + ) + self.blank = Flock.objects.create( + flock_name="Lantai 2", + kandang=self.kandang, + iot_api_url="", + ) + + def _csrf_headers(self, client: Client) -> dict[str, str]: + token = client.cookies["csrftoken"].value + return {"HTTP_X_CSRFTOKEN": token} + + def _login(self, client: Client) -> None: + client.get("/api/v1/auth/csrf/") + response = client.post( + "/api/v1/auth/login/", + {"user_name": "staff", "password": "staff123"}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 200, response.content) + + @patch("apps.operations.views.sync_iot_panels") + def test_sync_action_ok_for_staff(self, mock_sync): + mock_sync.return_value = type("S", (), {"upserted": 2, "skipped": 0, "pages": 1})() + client = Client(enforce_csrf_checks=True) + self._login(client) + response = client.post( + "/api/v1/panel-iot/sync/", + {"flock_id": self.flock.pk}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["upserted"], 2) + mock_sync.assert_called_once_with(flock_id=self.flock.pk) + + def test_sync_action_rejects_blank_url(self): + client = Client(enforce_csrf_checks=True) + self._login(client) + response = client.post( + "/api/v1/panel-iot/sync/", + {"flock_id": self.blank.pk}, + content_type="application/json", + **self._csrf_headers(client), + ) + self.assertEqual(response.status_code, 400, response.content) diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index 5ca7eb0..8151f28 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -8,7 +8,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView -from apps.farms.models import Cycle +from apps.farms.models import Cycle, Flock from apps.operations.models import ( AIInsight, ChickenCounting, @@ -33,6 +33,7 @@ from apps.operations.services.chicken_counting_edge import ( sync_chicken_counting_for_cycle, sync_chicken_weight_for_cycle, ) +from apps.operations.services.iot_api import IotApiError, sync_iot_panels from apps.operations.services.visibility import dashboard_publish_time, visible_through_date READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates", "cached"}) @@ -250,6 +251,39 @@ class IotPanelViewSet(viewsets.ModelViewSet): ) return Response(dates) + @action(detail=False, methods=["post"], url_path="sync") + def sync_from_api(self, request): + flock_id = request.data.get("flock_id") + if not flock_id: + return Response( + {"detail": "flock_id is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + flock = Flock.objects.get(pk=int(flock_id)) + except (Flock.DoesNotExist, TypeError, ValueError): + return Response({"detail": "Flock not found."}, status=status.HTTP_404_NOT_FOUND) + if not (flock.iot_api_url or "").strip(): + return Response( + {"detail": "Flock has no IoT API URL configured."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + summary = sync_iot_panels(flock_id=flock.pk) + except IotApiError as exc: + code = getattr(exc, "status_code", None) + return Response( + {"detail": str(exc)}, + status=code if code and code >= 400 else status.HTTP_502_BAD_GATEWAY, + ) + return Response( + { + "upserted": summary.upserted, + "skipped": summary.skipped, + "pages": summary.pages, + } + ) + class AIInsightViewSet(CycleScopedViewSet): queryset = AIInsight.objects.select_related("cycle", "kandang").all() diff --git a/backend/config/settings.py b/backend/config/settings.py index 44f3d32..1e41d84 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -195,13 +195,11 @@ DASHBOARD_PUBLISH_HOUR = int(env("DASHBOARD_PUBLISH_HOUR", "17") or "17") DASHBOARD_PUBLISH_MINUTE = int(env("DASHBOARD_PUBLISH_MINUTE", "0") or "0") # External IoT flocks API — panel sync every 10 minutes (real-time, not gated by publish cutoff). -IOT_API_BASE_URL = (env("IOT_API_BASE_URL", "https://dashboard.cpsp.id/api/iot/flocks") or "").rstrip("/") +# Per-flock source URLs live on Flock.iot_api_url (Settings); no global base URL / ID map. IOT_API_SYNC_TIMEOUT_SECONDS = float(env("IOT_API_SYNC_TIMEOUT_SECONDS", "60") or "60") IOT_SYNC_ENABLED = env_bool("IOT_SYNC_ENABLED", True) IOT_SYNC_LOOKBACK_MINUTES = int(env("IOT_SYNC_LOOKBACK_MINUTES", "20") or "20") IOT_SYNC_MAX_PAGES = int(env("IOT_SYNC_MAX_PAGES", "10") or "10") -# JSON map: external IoT flock_id -> Django flock.pk, e.g. {"686df69f407b21002da8750c": 1} -IOT_FLOCK_ID_MAP = env_json_dict("IOT_FLOCK_ID_MAP") # Chicken counting edge vision API (headcount + mortality) — not gated by publish cutoff. CHICKEN_COUNTING_EDGE_BASE_URL = ( diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 653d7f4..9ee9ce0 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -72,6 +72,9 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { null ); + const selectedFlock = flocks.find((flock) => flock.id === selectedFlockId) ?? null; + const hasIotUrl = Boolean((selectedFlock?.iot_api_url || '').trim()); + useEffect(() => { if (!selectedChartKey) return; const onKey = (event: KeyboardEvent) => { @@ -107,7 +110,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { return () => { cancelled = true; }; - }, [selectedKandang, refreshToken]); + }, [selectedKandang]); useEffect(() => { if (!selectedCycle || selectedFlockId == null) { @@ -259,12 +262,36 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const datePickerMin = selectedCycle?.start_date ?? undefined; const datePickerMax = selectedCycle ? resolveCycleBoundDate(selectedCycle) : undefined; - const refresh = () => { + const pullFromApi = async () => { + if (!selectedFlockId || !hasIotUrl) return; setRefreshing(true); - setRefreshToken((token) => token + 1); - window.setTimeout(() => setRefreshing(false), 500); + setError(null); + try { + await api.panelIot.sync({ flock_id: selectedFlockId }); + setRefreshToken((token) => token + 1); + } catch (err) { + setError(errorMessage(err)); + } finally { + setRefreshing(false); + } }; + useEffect(() => { + if (!selectedFlockId || !hasIotUrl) return; + const id = window.setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + void (async () => { + try { + await api.panelIot.sync({ flock_id: selectedFlockId }); + setRefreshToken((token) => token + 1); + } catch { + // Live follow stays quiet; manual pull surfaces errors. + } + })(); + }, 60_000); + return () => window.clearInterval(id); + }, [selectedFlockId, hasIotUrl]); + if (!selectedCycle) return ; return ( @@ -292,12 +319,13 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { } @@ -305,6 +333,12 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { {error && {error}} + {selectedFlockId != null && !hasIotUrl && ( + + Flock ini belum punya URL API Panel IoT. Setel di Pengaturan → Flock. + + )} + {selectedFlockId == null ? (

Pilih flock untuk melihat data panel IoT.

diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 868c2c4..6490bc1 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -57,6 +57,7 @@ const SettingsPage: React.FC = () => { const [kandangName, setKandangName] = useState(''); const [feedInButtonUrls, setFeedInButtonUrls] = useState([emptyFeedInButtonUrl()]); const [flockName, setFlockName] = useState(''); + const [flockIotUrls, setFlockIotUrls] = useState>({}); const [editingCycleId, setEditingCycleId] = useState(null); const [startDate, setStartDate] = useState(defaultCycleForm().startDate); const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount); @@ -138,6 +139,14 @@ const SettingsPage: React.FC = () => { setFeedInButtonUrls(urls.length > 0 ? urls.map((entry) => ({ ...entry })) : [emptyFeedInButtonUrl()]); }, [selectedKandang?.id, selectedKandang?.feed_in_button_urls]); + React.useEffect(() => { + const next: Record = {}; + for (const flock of flocks) { + next[flock.id] = flock.iot_api_url ?? ''; + } + setFlockIotUrls(next); + }, [flocks]); + const wrap = async (fn: () => Promise) => { setSaving(true); setError(null); @@ -508,21 +517,59 @@ const SettingsPage: React.FC = () => { ) : (
    {flocks.map((flock) => ( -
  • - {flock.flock_name} - {isSuperAdmin && ( - - )} +
  • +
    + {flock.flock_name} + {isSuperAdmin && ( + + )} +
    +
    + + {isSuperAdmin ? ( +
    + + setFlockIotUrls((prev) => ({ ...prev, [flock.id]: e.target.value })) + } + /> + +
    + ) : ( +

    + {(flock.iot_api_url || '').trim() || 'Belum diset (admin).'} +

    + )} +
  • ))}
diff --git a/components/__tests__/SettingsPage.test.tsx b/components/__tests__/SettingsPage.test.tsx index f00c704..03a0178 100644 --- a/components/__tests__/SettingsPage.test.tsx +++ b/components/__tests__/SettingsPage.test.tsx @@ -2,10 +2,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import SettingsPage from '../SettingsPage.tsx'; -const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, reloadMock, farmState } = vi.hoisted( +const { createKandangMock, createFlockMock, updateFlockMock, createCycleMock, updateCycleMock, reloadMock, farmState } = vi.hoisted( () => ({ createKandangMock: vi.fn(), createFlockMock: vi.fn(), + updateFlockMock: vi.fn(), createCycleMock: vi.fn(), updateCycleMock: vi.fn(), reloadMock: vi.fn().mockResolvedValue(undefined), @@ -21,6 +22,7 @@ const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, re flocks: [] as Array<{ id: number; flock_name: string; + iot_api_url?: string; kandang: number; created_at: string; updated_at: string; @@ -46,6 +48,7 @@ vi.mock('../../services/apiClient.ts', () => ({ }, flocks: { create: createFlockMock, + update: updateFlockMock, remove: vi.fn(), }, cycles: { @@ -91,6 +94,7 @@ describe('SettingsPage settings forms', () => { beforeEach(() => { createKandangMock.mockReset(); createFlockMock.mockReset(); + updateFlockMock.mockReset(); createCycleMock.mockReset(); updateCycleMock.mockReset(); reloadMock.mockClear(); @@ -111,6 +115,15 @@ describe('SettingsPage settings forms', () => { createFlockMock.mockResolvedValue({ id: 3, flock_name: 'Lantai 1', + iot_api_url: '', + kandang: 11, + created_at: '', + updated_at: '', + }); + updateFlockMock.mockResolvedValue({ + id: 3, + flock_name: 'Lantai 1', + iot_api_url: 'https://example.test/iot/lantai-1', kandang: 11, created_at: '', updated_at: '', @@ -179,6 +192,35 @@ describe('SettingsPage settings forms', () => { }); }); + it('saves flock IoT API URL for a listed flock', async () => { + farmState.kandangs = [ + { id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' }, + ]; + farmState.selectedKandangId = 11; + farmState.flocks = [ + { + id: 3, + flock_name: 'Lantai 1', + iot_api_url: '', + kandang: 11, + created_at: '', + updated_at: '', + }, + ]; + + render(); + + fireEvent.change(screen.getByPlaceholderText('https://…/api/iot/…'), { + target: { value: 'https://example.test/iot/lantai-1' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Simpan URL IoT' })); + + await waitFor(() => expect(updateFlockMock).toHaveBeenCalledTimes(1)); + expect(updateFlockMock).toHaveBeenCalledWith(3, { + iot_api_url: 'https://example.test/iot/lantai-1', + }); + }); + it('submits cycle fields without flock assignment', async () => { farmState.kandangs = [ { id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' }, diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx index 1a24f7d..4437d7f 100644 --- a/components/counting/DataManagementPage.tsx +++ b/components/counting/DataManagementPage.tsx @@ -358,8 +358,39 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } agg: 'lastValid', replaces: 'populasi_kini_ekor', }, + // Running totals on each tren row — take last of the day-sliced + // window so H29 does not keep full-cycle mortality/panen. + mortalitasKumulatif_ekor: { + field: 'mortalitas_kumulatif_ekor', + agg: 'last', + replaces: 'mortalitas_kumulatif_ekor', + emptyAs: 0, + }, + panenKumulatif_ekor: { + field: 'panen_kumulatif_ekor', + agg: 'last', + replaces: 'panen_kumulatif_ekor', + emptyAs: 0, + }, + beratPanenKumulatif_kg: { + field: 'berat_panen_kumulatif_kg', + agg: 'last', + replaces: 'berat_panen_kumulatif_kg', + emptyAs: 0, + }, + mortalitasPersen: { + field: 'mortalitas_persen', + agg: 'last', + replaces: 'mortalitas_persen', + }, }, - drop: ['populasi_kini_ekor'], + drop: [ + 'populasi_kini_ekor', + 'mortalitas_kumulatif_ekor', + 'panen_kumulatif_ekor', + 'berat_panen_kumulatif_kg', + 'mortalitas_persen', + ], }} />
diff --git a/components/shared/PageAiInsight.tsx b/components/shared/PageAiInsight.tsx index 57efa00..e7a23a9 100644 --- a/components/shared/PageAiInsight.tsx +++ b/components/shared/PageAiInsight.tsx @@ -1,10 +1,11 @@ /** * Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …). * - * Builds/scopes context, then loads cached insight on mount. - * LLM generate runs only when the user clicks Generate (force=true). - * Distinct from - * `AiInsightCard` (Dashboard report modes). See docs/ai-insight/README.md. + * Builds/scopes context, then loads the insight from the DB on mount + * (`GET /ai-insights/cached/`). LLM generate runs only when the user clicks + * Generate (force=true). No client memory/sessionStorage for insight text — + * the DB row is the source of truth. + * Distinct from `AiInsightCard` (Dashboard report modes). */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useFarm } from '../../context/FarmContext.tsx'; @@ -30,13 +31,13 @@ import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx'; const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes -const _insightMemoryCache = new Map(); -const _insightInFlight = new Map>(); - -type CachedPageInsight = AiResult & { +type PageInsightResult = AiResult & { citations?: InsightCitation[]; }; +/** Dedupes concurrent soft DB loads for the same scope (not a content cache). */ +const _insightInFlight = new Map>(); + export type { InsightTopic }; export interface PageAiInsightProps { @@ -73,6 +74,20 @@ const renderValue = (val: unknown, suffix: string = ''): string => { return `${val} ${suffix}`.trim(); }; +/** Match FeedSaldoCard: masuk IOT + saldo awal. */ +const feedMasukTotal = (ctx: Record): number | null => { + const masuk = + typeof ctx.total_karung_masuk_iot === 'number' && Number.isFinite(ctx.total_karung_masuk_iot) + ? ctx.total_karung_masuk_iot + : null; + const saldo = + typeof ctx.saldo_awal_karung === 'number' && Number.isFinite(ctx.saldo_awal_karung) + ? ctx.saldo_awal_karung + : null; + if (masuk === null && saldo === null) return null; + return (masuk ?? 0) + (saldo ?? 0); +}; + const requireKandangId = (ctx: Record): number => { const raw = ctx.kandangId ?? ctx.kandang_id; const id = typeof raw === 'number' ? raw : Number(raw); @@ -82,15 +97,6 @@ const requireKandangId = (ctx: Record): number => { return id; }; -const _saveToCache = (storageKey: string, result: CachedPageInsight): void => { - _insightMemoryCache.set(storageKey, result); - try { - sessionStorage.setItem(storageKey, JSON.stringify(result)); - } catch { - /* ignore */ - } -}; - const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => { if (!citations || citations.length === 0) return null; return ( @@ -121,7 +127,7 @@ export const PageAiInsight: React.FC = ({ }) => { const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const supportsTimeframe = Boolean(scalarScope); @@ -350,8 +356,10 @@ export const PageAiInsight: React.FC = ({ if (actual === null) return null; const manual = n(displayContext.total_karung_dituang_manual); const perDay = n(displayContext.rata_karung_per_hari); + const masukTotal = feedMasukTotal(displayContext); const parts = [ - manual === null ? null : `Manual: ${manual.toLocaleString('id-ID')} karung`, + masukTotal === null ? null : `Masuk: ${masukTotal.toLocaleString('id-ID')} karung`, + manual === null ? null : `Manual dituang: ${manual.toLocaleString('id-ID')} karung`, perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`, ].filter(Boolean); return { @@ -364,6 +372,33 @@ export const PageAiInsight: React.FC = ({ }; } + if (topic === 'hitung_ayam') { + const actual = n(displayContext.populasi_kini_ekor); + if (actual === null) return null; + const awal = n(displayContext.populasi_awal_ekor); + const mortPct = n(displayContext.mortalitas_persen); + return { + label: 'Populasi Kini', + value: Math.round(actual).toLocaleString('id-ID'), + unit: 'ekor', + context: + awal === null + ? null + : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`, + status: (mortPct === null + ? 'unknown' + : mortPct < 5 + ? 'ok' + : mortPct <= 7 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: + mortPct === null + ? null + : `Mortalitas ${mortPct.toLocaleString('id-ID', { maximumFractionDigits: 2 })}%`, + }; + } + return null; }, [topic, displayContext, currentDay, effectiveDay]); @@ -375,68 +410,16 @@ export const PageAiInsight: React.FC = ({ const requestId = ++requestIdRef.current; const isCurrent = () => requestIdRef.current === requestId; - if (force) { - _insightMemoryCache.delete(storageKey); - try { - sessionStorage.removeItem(storageKey); - } catch { - /* ignore */ - } - } - - if (!force) { - const memCached = _insightMemoryCache.get(storageKey); - if (memCached) { - if (!isCurrent()) return; - setResult(memCached); - setLoading(false); - lastCacheKeyRef.current = scopeKey; - return; - } - } - + // Soft load: share one in-flight DB GET for the same scope (no client content cache). if (!force && _insightInFlight.has(storageKey)) { - await _insightInFlight.get(storageKey); + const shared = await _insightInFlight.get(storageKey); if (!isCurrent()) return; - const memCached = _insightMemoryCache.get(storageKey); - if (memCached) { - setResult(memCached); - setLoading(false); - lastCacheKeyRef.current = scopeKey; - } + setResult(shared ?? null); + setLoading(false); + lastCacheKeyRef.current = scopeKey; return; } - if (!force) { - try { - const raw = sessionStorage.getItem(storageKey); - if (raw) { - const parsedJson = JSON.parse(raw) as CachedPageInsight; - if (parsedJson?.summary || parsedJson?.insight) { - _insightMemoryCache.set(storageKey, parsedJson); - if (!isCurrent()) return; - setResult(parsedJson); - setLoading(false); - lastCacheKeyRef.current = scopeKey; - return; - } - const parsed = parseAiResult(raw); - if (parsed) { - const wrapped: CachedPageInsight = { ...parsed }; - _insightMemoryCache.set(storageKey, wrapped); - if (!isCurrent()) return; - setResult(wrapped); - setLoading(false); - lastCacheKeyRef.current = scopeKey; - return; - } - sessionStorage.removeItem(storageKey); - } - } catch { - /* ignore */ - } - } - if (inFlightRef.current && !force) return; inFlightRef.current = true; // Only show the LLM loading UI when the user clicks Generate. @@ -446,7 +429,7 @@ export const PageAiInsight: React.FC = ({ } setError(null); - const fetchPromise = (async () => { + const fetchPromise = (async (): Promise => { try { if (selectedKandangId == null && !(contextData as Record).kandangId) { throw new Error( @@ -511,7 +494,12 @@ export const PageAiInsight: React.FC = ({ selectedKandang?.kandang_name ?? undefined, cycleId: selectedCycle.id, - hari_ke: typeof effectiveDay === 'number' ? effectiveDay : typeof currentDayVal === 'number' ? currentDayVal : null, + hari_ke: + typeof effectiveDay === 'number' + ? effectiveDay + : typeof currentDayVal === 'number' + ? currentDayVal + : null, totalDays: maxDays ?? null, periode: timeframeLabel, }; @@ -533,11 +521,11 @@ export const PageAiInsight: React.FC = ({ } // Manual-generate only: never POST generate on mount / soft refresh. if (!apiResult) { - if (!isCurrent()) return; + if (!isCurrent()) return null; setResult(null); setLoading(false); lastCacheKeyRef.current = scopeKey; - return; + return null; } } else { const generateCall = api.insights.generate({ @@ -563,7 +551,7 @@ export const PageAiInsight: React.FC = ({ apiResult = await Promise.race([generateCall, timeoutPromise]); } - if (!isCurrent()) return; + if (!isCurrent()) return null; if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) { throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.'); @@ -582,26 +570,27 @@ export const PageAiInsight: React.FC = ({ throw new Error('Respons AI tidak dapat diproses.'); } - const wrapped: CachedPageInsight = { + const wrapped: PageInsightResult = { summary: parsed.summary, insight: parsed.insight, citations: apiResult.citations, }; - _saveToCache(storageKey, wrapped); - if (!isCurrent()) return; + if (!isCurrent()) return wrapped; setResult(wrapped); lastCacheKeyRef.current = scopeKey; + return wrapped; } catch (err) { - if (!isCurrent()) return; + if (!isCurrent()) return null; console.error(`[PageAiInsight:${topic}] request failed:`, err); - // Soft cache misses/errors should not look like a failed generate. + // Soft DB misses/errors should not look like a failed generate. if (force) { setError(errorMessage(err)); setResult(null); } else { setResult(null); } + return null; } finally { if (isCurrent()) { setLoading(false); @@ -611,7 +600,9 @@ export const PageAiInsight: React.FC = ({ } })(); - _insightInFlight.set(storageKey, fetchPromise); + if (!force) { + _insightInFlight.set(storageKey, fetchPromise); + } await fetchPromise; }, [ @@ -634,7 +625,7 @@ export const PageAiInsight: React.FC = ({ generateRef.current = generate; }); - // Day / scope change: drop previous insight immediately and only soft-load cache (no generate UI). + // Day / scope change: clear UI and soft-load from DB (no generate). useEffect(() => { if (lastCacheKeyRef.current === scopeKey) return; requestIdRef.current += 1; @@ -865,9 +856,56 @@ export const PageAiInsight: React.FC = ({ {topic === 'hitung_karung' && ( <>
- Saldo Awal Pakan + Total Karung Masuk - {renderValue(displayContext.saldo_awal_karung, 'Karung')} + {renderValue(feedMasukTotal(displayContext), 'Karung')} + +
+ + )} + + {topic === 'hitung_ayam' && ( + <> +
+ Populasi Awal + + {renderValue(displayContext.populasi_awal_ekor, 'Ekor')} + +
+
+ Populasi Kini + + {renderValue(displayContext.populasi_kini_ekor, 'Ekor')} + +
+
+ + Mortalitas Kumulatif + + + {renderValue(displayContext.mortalitas_kumulatif_ekor, 'Ekor')} + +
+
+ Mortalitas + + {typeof displayContext.mortalitas_persen === 'number' + ? `${displayContext.mortalitas_persen.toLocaleString('id-ID', { + maximumFractionDigits: 2, + })} %` + : renderValue(displayContext.mortalitas_persen, '%')} + +
+
+ Panen Kumulatif + + {renderValue(displayContext.panen_kumulatif_ekor, 'Ekor')} + +
+
+ Berat Panen + + {renderValue(displayContext.berat_panen_kumulatif_kg, 'kg')}
diff --git a/docker-compose.yml b/docker-compose.yml index 6777b17..c1965bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,8 +89,6 @@ services: DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17} DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0} IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-true} - IOT_API_BASE_URL: ${IOT_API_BASE_URL:-https://dashboard.cpsp.id/api/iot/flocks} - IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}} IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20} IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10} CHICKEN_COUNTING_EDGE_BASE_URL: ${CHICKEN_COUNTING_EDGE_BASE_URL:-} @@ -159,8 +157,6 @@ services: DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-9} DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-30} IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-true} - IOT_API_BASE_URL: ${IOT_API_BASE_URL:-https://dashboard.cpsp.id/api/iot/flocks} - IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}} IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20} IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10} CHICKEN_COUNTING_EDGE_BASE_URL: ${CHICKEN_COUNTING_EDGE_BASE_URL:-} diff --git a/docker/.env.example b/docker/.env.example index 8f6012b..947c48d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -38,12 +38,11 @@ KARUNG_WEB_ADMIN_SYNC_ENABLED=false DASHBOARD_PUBLISH_HOUR=17 DASHBOARD_PUBLISH_MINUTE=0 -# IoT panel — real-time sync every 10 min (excluded from publish cutoff) +# IoT panel — real-time sync every 10 min (excluded from publish cutoff). +# Configure per-flock API URLs in Settings (Flock → API Panel IoT). IOT_SYNC_ENABLED=true -IOT_API_BASE_URL=https://dashboard.cpsp.id/api/iot/flocks IOT_SYNC_LOOKBACK_MINUTES=20 IOT_SYNC_MAX_PAGES=10 -IOT_FLOCK_ID_MAP={} # Chicken counting edge vision API (counting + mortality + weight; not gated by publish cutoff) CHICKEN_COUNTING_EDGE_BASE_URL=http://192.168.192.106:8090 diff --git a/services/apiClient.ts b/services/apiClient.ts index d1a9778..b87002a 100644 --- a/services/apiClient.ts +++ b/services/apiClient.ts @@ -252,6 +252,8 @@ export const api = { list: async (query?: CycleFilter) => unwrapList(await get | PanelIoT[]>('/panel-iot/', query)), dates: async (query?: CycleFilter) => get('/panel-iot/dates/', query), + sync: (body: { flock_id: number }) => + post<{ upserted: number; skipped: number; pages: number }>('/panel-iot/sync/', body), }, insights: { list: async (query?: CycleFilter) => @@ -267,7 +269,7 @@ export const api = { } return post('/ai-insights/generate/', body); }, - /** Fetch cached insight for the same scope key (no LLM call). */ + /** Load insight from DB for the same scope key (no LLM call). */ cached: (params: InsightCachedParams) => { if (params.kandang_id == null) { return Promise.reject(new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')); diff --git a/types/api.ts b/types/api.ts index 0a3b634..fed9563 100644 --- a/types/api.ts +++ b/types/api.ts @@ -49,6 +49,7 @@ export type Kandang = { export type Flock = { id: number; flock_name: string; + iot_api_url?: string; kandang: number; created_at: string; updated_at: string; @@ -356,8 +357,9 @@ export type KandangWrite = { feed_in_button_urls?: FeedInButtonUrl[]; }; export type FlockWrite = { - flock_name: string; - kandang: number; + flock_name?: string; + kandang?: number; + iot_api_url?: string; }; export type CycleWrite = { kandang: number; diff --git a/utils/__tests__/countingInsightScope.test.ts b/utils/__tests__/countingInsightScope.test.ts new file mode 100644 index 0000000..d62aa74 --- /dev/null +++ b/utils/__tests__/countingInsightScope.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { buildCountingInsightContext } from '../insightContextBuilders.ts'; +import { scopeContextToTimeframe, scopeScalarsToTimeframe } from '../insightScope.ts'; + +const countingScalarScope = { + historyKey: 'tren_populasi_harian', + derive: { + populasiKini_ekor: { + field: 'populasi', + agg: 'lastValid' as const, + replaces: 'populasi_kini_ekor', + }, + mortalitasKumulatif_ekor: { + field: 'mortalitas_kumulatif_ekor', + agg: 'last' as const, + replaces: 'mortalitas_kumulatif_ekor', + emptyAs: 0 as const, + }, + panenKumulatif_ekor: { + field: 'panen_kumulatif_ekor', + agg: 'last' as const, + replaces: 'panen_kumulatif_ekor', + emptyAs: 0 as const, + }, + beratPanenKumulatif_kg: { + field: 'berat_panen_kumulatif_kg', + agg: 'last' as const, + replaces: 'berat_panen_kumulatif_kg', + emptyAs: 0 as const, + }, + mortalitasPersen: { + field: 'mortalitas_persen', + agg: 'last' as const, + replaces: 'mortalitas_persen', + }, + }, + drop: [ + 'populasi_kini_ekor', + 'mortalitas_kumulatif_ekor', + 'panen_kumulatif_ekor', + 'berat_panen_kumulatif_kg', + 'mortalitas_persen', + ], +}; + +describe('hitung ayam insight day scope', () => { + it('does not keep full-cycle mortality/panen when day is capped', () => { + const ctx = buildCountingInsightContext({ + kandangId: 1, + populasi_awal_ekor: 45_000, + // Stale full-cycle scalars — must be replaced by day-29 window. + populasi_kini_ekor: 100, + mortalitas_kumulatif_ekor: 4269, + mortalitas_persen: 9.49, + panen_kumulatif_ekor: 40_700, + berat_panen_kumulatif_kg: 57_469.4, + dailyRows: [ + { day: 0, stockAkhir: 44_800, mati: 200, panen: 0, beratPanen: 0 }, + { day: 29, stockAkhir: 42_000, mati: 100, panen: 500, beratPanen: 700 }, + { day: 48, stockAkhir: 100, mati: 3969, panen: 40_200, beratPanen: 56_769.4 }, + ], + }); + + const sliced = scopeContextToTimeframe(ctx, 'harian', 29); + const scoped = scopeScalarsToTimeframe( + sliced as Record, + 'harian', + countingScalarScope, + 29 + ); + + expect(scoped.populasiKini_ekor).toBe(42_000); + // Days 0+29 only: 200+100 mati, 0+500 panen, 0+700 kg + expect(scoped.mortalitasKumulatif_ekor).toBe(300); + expect(scoped.panenKumulatif_ekor).toBe(500); + expect(scoped.beratPanenKumulatif_kg).toBe(700); + expect(scoped.mortalitasPersen).toBe(0.67); + expect(scoped.periode).toBe('hari ke-0 s/d ke-29'); + }); +}); diff --git a/utils/insightContextBuilders.ts b/utils/insightContextBuilders.ts index 5b5bfc2..4f8b8d7 100644 --- a/utils/insightContextBuilders.ts +++ b/utils/insightContextBuilders.ts @@ -397,6 +397,11 @@ export type CountingInsightRow = { populasi?: number | null; mati?: number | null; panen?: number | null; + beratPanen?: number | null; + /** Running totals when the page already computed them (preferred). */ + mortalityTotal?: number | null; + harvestTotal?: number | null; + harvestWeightTotal?: number | null; chickenCount?: number | null; [key: string]: unknown; }; @@ -421,30 +426,58 @@ export function buildCountingInsightContext(args: BuildCountingInsightContextArg const kandangId = requireKandangId(args.kandangId, 'hitung ayam'); const rows = Array.isArray(args.dailyRows) ? args.dailyRows : []; - const tren = rows + const populasi_awal = args.populasi_awal_ekor ?? null; + + // Sort first, then accumulate — unsorted input would corrupt running totals. + const ordered = [...rows] .map((row) => { const hari = dayOf(row); if (hari === null) return null; - return { - hari, - populasi: num(row.stockAkhir) ?? num(row.populasi), - mati_ekor: num(row.mati), - panen_ekor: num(row.panen), - chicken_count_iot_ekor: num(row.chickenCount), - }; + return { row, hari }; }) - .filter((row): row is NonNullable => row !== null) + .filter((item): item is { row: CountingInsightRow; hari: number } => item !== null) .sort((a, b) => a.hari - b.hari); + let runMort = 0; + let runPanen = 0; + let runBerat = 0; + const tren = ordered.map(({ row, hari }) => { + const mati = num(row.mati) ?? 0; + const panen = num(row.panen) ?? 0; + const berat = num(row.beratPanen) ?? 0; + runMort += mati; + runPanen += panen; + runBerat += berat; + const mortCum = num(row.mortalityTotal) ?? runMort; + const panenCum = num(row.harvestTotal) ?? runPanen; + const beratCum = num(row.harvestWeightTotal) ?? runBerat; + const mortPct = + populasi_awal != null && populasi_awal > 0 + ? Math.round((mortCum / populasi_awal) * 10000) / 100 + : null; + return { + hari, + populasi: num(row.stockAkhir) ?? num(row.populasi), + mati_ekor: num(row.mati), + panen_ekor: num(row.panen), + chicken_count_iot_ekor: num(row.chickenCount), + mortalitas_kumulatif_ekor: mortCum, + panen_kumulatif_ekor: panenCum, + berat_panen_kumulatif_kg: Math.round(beratCum * 100) / 100, + mortalitas_persen: mortPct, + }; + }); + const latest = tren[tren.length - 1]; - const populasi_awal = args.populasi_awal_ekor ?? null; - const mortalitas_ekor = args.mortalitas_kumulatif_ekor ?? null; + const mortalitas_ekor = + args.mortalitas_kumulatif_ekor ?? latest?.mortalitas_kumulatif_ekor ?? null; const mortalitas_persen = args.mortalitas_persen !== undefined ? args.mortalitas_persen - : populasi_awal != null && populasi_awal > 0 && mortalitas_ekor != null - ? (mortalitas_ekor / populasi_awal) * 100 - : null; + : latest?.mortalitas_persen ?? + (populasi_awal != null && populasi_awal > 0 && mortalitas_ekor != null + ? Math.round((mortalitas_ekor / populasi_awal) * 10000) / 100 + : null); return { kandangId, @@ -456,8 +489,9 @@ export function buildCountingInsightContext(args: BuildCountingInsightContextArg populasi_kini_ekor: args.populasi_kini_ekor ?? latest?.populasi ?? null, mortalitas_kumulatif_ekor: mortalitas_ekor, mortalitas_persen, - panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? null, - berat_panen_kumulatif_kg: args.berat_panen_kumulatif_kg ?? null, + panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? latest?.panen_kumulatif_ekor ?? null, + berat_panen_kumulatif_kg: + args.berat_panen_kumulatif_kg ?? latest?.berat_panen_kumulatif_kg ?? null, tren_populasi_harian: tren, }; }