update api configuration for panel iot and adjust ai insight
This commit is contained in:
1 parent
a2b5b10977
commit
20d04acb3e
20 files changed
+814
-177
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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, "")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
@@ -292,12 +319,13 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
onClick={() => void pullFromApi()}
|
||||
disabled={refreshing || !hasIotUrl}
|
||||
className="rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100 disabled:opacity-60"
|
||||
title={hasIotUrl ? 'Tarik data dari API IoT' : 'Setel URL IoT di Pengaturan'}
|
||||
>
|
||||
<i className={`fa-solid fa-rotate-right mr-1 ${refreshing ? 'fa-spin' : ''}`} />
|
||||
Refresh
|
||||
{refreshing ? 'Sinkron…' : 'Tarik API'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -305,6 +333,12 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
|
||||
{error && <Banner kind="error">{error}</Banner>}
|
||||
|
||||
{selectedFlockId != null && !hasIotUrl && (
|
||||
<Banner kind="error">
|
||||
Flock ini belum punya URL API Panel IoT. Setel di Pengaturan → Flock.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{selectedFlockId == null ? (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">Pilih flock untuk melihat data panel IoT.</p>
|
||||
|
||||
+62
-15
@@ -57,6 +57,7 @@ const SettingsPage: React.FC = () => {
|
||||
const [kandangName, setKandangName] = useState('');
|
||||
const [feedInButtonUrls, setFeedInButtonUrls] = useState<FeedInButtonUrl[]>([emptyFeedInButtonUrl()]);
|
||||
const [flockName, setFlockName] = useState('');
|
||||
const [flockIotUrls, setFlockIotUrls] = useState<Record<number, string>>({});
|
||||
const [editingCycleId, setEditingCycleId] = useState<number | null>(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<number, string> = {};
|
||||
for (const flock of flocks) {
|
||||
next[flock.id] = flock.iot_api_url ?? '';
|
||||
}
|
||||
setFlockIotUrls(next);
|
||||
}, [flocks]);
|
||||
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -508,21 +517,59 @@ const SettingsPage: React.FC = () => {
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{flocks.map((flock) => (
|
||||
<li key={flock.id} className="py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-gray-700">{flock.flock_name}</span>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
onClick={() =>
|
||||
void wrap(async () => {
|
||||
await api.flocks.remove(flock.id);
|
||||
flash('Flock dihapus.');
|
||||
})
|
||||
}
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
)}
|
||||
<li key={flock.id} className="py-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">{flock.flock_name}</span>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
onClick={() =>
|
||||
void wrap(async () => {
|
||||
await api.flocks.remove(flock.id);
|
||||
flash('Flock dihapus.');
|
||||
})
|
||||
}
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-gray-500">API Panel IoT</label>
|
||||
{isSuperAdmin ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="text"
|
||||
placeholder="https://…/api/iot/…"
|
||||
value={flockIotUrls[flock.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setFlockIotUrls((prev) => ({ ...prev, [flock.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
void wrap(async () => {
|
||||
await api.flocks.update(flock.id, {
|
||||
iot_api_url: (flockIotUrls[flock.id] ?? '').trim(),
|
||||
});
|
||||
flash(`URL IoT ${flock.flock_name} disimpan.`);
|
||||
})
|
||||
}
|
||||
>
|
||||
Simpan URL IoT
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="break-all text-xs text-gray-600">
|
||||
{(flock.iot_api_url || '').trim() || 'Belum diset (admin).'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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(<SettingsPage />);
|
||||
|
||||
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: '' },
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string, CachedPageInsight>();
|
||||
const _insightInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
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<string, Promise<PageInsightResult | null>>();
|
||||
|
||||
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<string, unknown>): 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<string, unknown>): number => {
|
||||
const raw = ctx.kandangId ?? ctx.kandang_id;
|
||||
const id = typeof raw === 'number' ? raw : Number(raw);
|
||||
@@ -82,15 +97,6 @@ const requireKandangId = (ctx: Record<string, unknown>): 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<PageAiInsightProps> = ({
|
||||
}) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
|
||||
const [result, setResult] = useState<CachedPageInsight | null>(null);
|
||||
const [result, setResult] = useState<PageInsightResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const supportsTimeframe = Boolean(scalarScope);
|
||||
@@ -350,8 +356,10 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
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<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
|
||||
throw new Error(
|
||||
@@ -511,7 +494,12 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
}
|
||||
// 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<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
}
|
||||
})();
|
||||
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
if (!force) {
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
}
|
||||
await fetchPromise;
|
||||
},
|
||||
[
|
||||
@@ -634,7 +625,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
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<PageAiInsightProps> = ({
|
||||
{topic === 'hitung_karung' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Saldo Awal Pakan</span>
|
||||
<span className="text-sm text-gray-600 font-medium">Total Karung Masuk</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.saldo_awal_karung, 'Karung')}
|
||||
{renderValue(feedMasukTotal(displayContext), 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'hitung_ayam' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Populasi Awal</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.populasi_awal_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Populasi Kini</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.populasi_kini_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Mortalitas Kumulatif
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.mortalitas_kumulatif_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Mortalitas</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.mortalitas_persen === 'number'
|
||||
? `${displayContext.mortalitas_persen.toLocaleString('id-ID', {
|
||||
maximumFractionDigits: 2,
|
||||
})} %`
|
||||
: renderValue(displayContext.mortalitas_persen, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Panen Kumulatif</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.panen_kumulatif_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Berat Panen</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.berat_panen_kumulatif_kg, 'kg')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
@@ -252,6 +252,8 @@ export const api = {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<PanelIoT> | PanelIoT[]>('/panel-iot/', query)),
|
||||
dates: async (query?: CycleFilter) => get<string[]>('/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<InsightApiResponse>('/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'));
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
'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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof row> => 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user