update api configuration for panel iot and adjust ai insight

This commit is contained in:
Alberto-Audrix committed 2026-09-18 16:06:41 +07:00
1 parent a2b5b10977
commit 20d04acb3e
20 files changed
+814 -177

No files matched your search

+2 -3
View File
@@ -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),
),
]
+1
View File
@@ -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,
+4 -1
View File
@@ -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())
+96
View File
@@ -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, "")
+56 -29
View File
@@ -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)
+159
View File
@@ -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)
+35 -1
View File
@@ -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()
+1 -3
View File
@@ -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 = (
+41 -7
View File
@@ -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
View File
@@ -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>
+43 -1
View File
@@ -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: '' },
+32 -1
View File
@@ -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>
+128 -90
View File
@@ -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>
</>
-4
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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');
});
});
+50 -16
View File
@@ -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,
};
}