add chicken counting, mortality api for daily data sync. Also adjust karung sync to sync every 30 sec after publish cutoff time to update real data without clicking sync button manually
This commit is contained in:
1 parent
5ae477a99d
commit
6c21af745d
17 files changed
+791
-119
No files matched your search
@@ -0,0 +1,45 @@
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.farms.models import Cycle
|
||||
from apps.operations.services.chicken_counting_edge import (
|
||||
ChickenCountingEdgeError,
|
||||
sync_chicken_counting_for_cycle,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Sync chicken counting + mortality IoT from the edge vision API"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--cycle-id", type=int, default=None)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
counting_on = settings.CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED
|
||||
mortality_on = settings.CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED
|
||||
if not counting_on and not mortality_on:
|
||||
self.stdout.write(
|
||||
"CHICKEN_COUNTING_EDGE_*_SYNC_ENABLED=false; skipping"
|
||||
)
|
||||
return
|
||||
|
||||
cycle_id = options.get("cycle_id")
|
||||
if cycle_id:
|
||||
cycles = Cycle.objects.filter(pk=cycle_id).select_related("kandang")
|
||||
else:
|
||||
cycles = Cycle.objects.filter(status=Cycle.STATUS_ACTIVE).select_related(
|
||||
"kandang"
|
||||
)
|
||||
|
||||
for cycle in cycles:
|
||||
try:
|
||||
summary = sync_chicken_counting_for_cycle(cycle)
|
||||
except ChickenCountingEdgeError as exc:
|
||||
self.stderr.write(f"cycle={cycle.pk}: {exc}")
|
||||
continue
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"cycle={cycle.pk} upserted={summary['upserted']} "
|
||||
f"dates={','.join(d.isoformat() for d in summary['dates']) or '-'}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.farms.models import Cycle
|
||||
from apps.operations.models import ChickenCounting
|
||||
|
||||
|
||||
class ChickenCountingEdgeError(Exception):
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def kandang_index(kandang_name: str) -> str:
|
||||
match = re.search(r"(\d+)", kandang_name or "")
|
||||
if not match:
|
||||
raise ValidationError(
|
||||
f"Cannot derive edge kandang index from kandang name: {kandang_name!r}"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def location_prefix_for_kandang(kandang_name: str) -> str:
|
||||
"""``Kandang 1`` → ``K1`` (matches floor prefixes like ``K1-L1``)."""
|
||||
return f"K{kandang_index(kandang_name)}"
|
||||
|
||||
|
||||
def coop_for_kandang(kandang_name: str) -> str:
|
||||
"""``Kandang 1`` → ``K1`` (mortality ``coop`` field)."""
|
||||
return location_prefix_for_kandang(kandang_name)
|
||||
|
||||
|
||||
def _parse_history_date(raw: Any) -> date | None:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def aggregate_counting_by_date(
|
||||
rows: list[dict[str, Any]] | None,
|
||||
*,
|
||||
kandang_name: str,
|
||||
) -> dict[date, int]:
|
||||
"""Sum ``total`` for floors whose location starts with ``KN-`` / equals ``KN``."""
|
||||
prefix = location_prefix_for_kandang(kandang_name)
|
||||
by_date: dict[date, int] = {}
|
||||
for row in rows or []:
|
||||
day = _parse_history_date(row.get("date"))
|
||||
if day is None:
|
||||
continue
|
||||
location = str(row.get("location") or "").strip()
|
||||
if location != prefix and not location.startswith(f"{prefix}-"):
|
||||
continue
|
||||
by_date[day] = by_date.get(day, 0) + int(row.get("total") or 0)
|
||||
return by_date
|
||||
|
||||
|
||||
def aggregate_mortality_by_date(
|
||||
rows: list[dict[str, Any]] | None,
|
||||
*,
|
||||
kandang_name: str,
|
||||
) -> dict[date, int]:
|
||||
"""Map date → ``total_mortality_count`` for the matching coop."""
|
||||
expected = coop_for_kandang(kandang_name).casefold()
|
||||
by_date: dict[date, int] = {}
|
||||
for row in rows or []:
|
||||
day = _parse_history_date(row.get("date"))
|
||||
if day is None:
|
||||
continue
|
||||
coop = str(row.get("coop") or row.get("location") or "").strip()
|
||||
if coop.casefold() != expected:
|
||||
continue
|
||||
by_date[day] = int(row.get("total_mortality_count") or 0)
|
||||
return by_date
|
||||
|
||||
|
||||
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
|
||||
if today is None:
|
||||
today = timezone.localdate()
|
||||
end = min(today, cycle.end_date)
|
||||
return cycle.start_date, end
|
||||
|
||||
|
||||
class ChickenCountingEdgeClient:
|
||||
"""Outbound client for the chicken-counting edge box."""
|
||||
|
||||
DB_HISTORY_PATH = "/api/db/history"
|
||||
MORTALITY_HISTORY_PATH = "/api/mortality/history"
|
||||
|
||||
def __init__(self, base_url: str | None = None, timeout: float | None = None):
|
||||
self.base_url = (
|
||||
base_url or getattr(settings, "CHICKEN_COUNTING_EDGE_BASE_URL", "") or ""
|
||||
).rstrip("/")
|
||||
self.timeout = timeout or float(
|
||||
getattr(settings, "CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS", 30) or 30
|
||||
)
|
||||
|
||||
def _get_json(self, path: str) -> Any:
|
||||
if not self.base_url:
|
||||
raise ChickenCountingEdgeError("CHICKEN_COUNTING_EDGE_BASE_URL is not configured")
|
||||
url = urljoin(self.base_url + "/", path.lstrip("/"))
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
response = client.get(url)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ChickenCountingEdgeError(f"chicken counting edge request failed: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise ChickenCountingEdgeError(
|
||||
f"chicken counting edge HTTP {response.status_code}: {response.text[:300]}",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def fetch_db_history(self) -> list[dict[str, Any]]:
|
||||
payload = self._get_json(self.DB_HISTORY_PATH)
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return list(payload.get("results") or payload.get("history") or [])
|
||||
|
||||
def fetch_mortality_history(self) -> list[dict[str, Any]]:
|
||||
payload = self._get_json(self.MORTALITY_HISTORY_PATH)
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return list(payload.get("results") or payload.get("history") or [])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_chicken_counting_for_cycle(
|
||||
cycle: Cycle,
|
||||
*,
|
||||
db_history: list[dict[str, Any]] | None = None,
|
||||
mortality_history: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert ChickenCounting from edge history within the cycle window."""
|
||||
counting_enabled = bool(
|
||||
getattr(settings, "CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED", True)
|
||||
)
|
||||
mortality_enabled = bool(
|
||||
getattr(settings, "CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", True)
|
||||
)
|
||||
if not counting_enabled and not mortality_enabled:
|
||||
return {"dates": [], "upserted": 0, "skipped": True}
|
||||
|
||||
client = ChickenCountingEdgeClient()
|
||||
if db_history is None and counting_enabled:
|
||||
db_history = client.fetch_db_history()
|
||||
if mortality_history is None and mortality_enabled:
|
||||
mortality_history = client.fetch_mortality_history()
|
||||
|
||||
kandang_name = cycle.kandang.kandang_name
|
||||
counting_by_date = (
|
||||
aggregate_counting_by_date(db_history, kandang_name=kandang_name)
|
||||
if counting_enabled
|
||||
else {}
|
||||
)
|
||||
mortality_by_date = (
|
||||
aggregate_mortality_by_date(mortality_history, kandang_name=kandang_name)
|
||||
if mortality_enabled
|
||||
else {}
|
||||
)
|
||||
|
||||
start, end = cycle_history_window(cycle)
|
||||
dates = sorted(set(counting_by_date) | set(mortality_by_date))
|
||||
upserted: list[date] = []
|
||||
|
||||
for day in dates:
|
||||
if day < start or day > end:
|
||||
continue
|
||||
defaults: dict[str, int] = {}
|
||||
if counting_enabled and day in counting_by_date:
|
||||
defaults["total_count"] = counting_by_date[day]
|
||||
if mortality_enabled and day in mortality_by_date:
|
||||
defaults["mortality_count"] = mortality_by_date[day]
|
||||
if not defaults:
|
||||
continue
|
||||
|
||||
obj, created = ChickenCounting.objects.get_or_create(
|
||||
cycle=cycle,
|
||||
date=day,
|
||||
defaults={
|
||||
"total_count": defaults.get("total_count", 0),
|
||||
"mortality_count": defaults.get("mortality_count", 0),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
update_fields = []
|
||||
if "total_count" in defaults and obj.total_count != defaults["total_count"]:
|
||||
obj.total_count = defaults["total_count"]
|
||||
update_fields.append("total_count")
|
||||
if (
|
||||
"mortality_count" in defaults
|
||||
and obj.mortality_count != defaults["mortality_count"]
|
||||
):
|
||||
obj.mortality_count = defaults["mortality_count"]
|
||||
update_fields.append("mortality_count")
|
||||
if update_fields:
|
||||
update_fields.append("updated_at")
|
||||
obj.save(update_fields=update_fields)
|
||||
upserted.append(day)
|
||||
|
||||
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
|
||||
@@ -0,0 +1,213 @@
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.farms.models import Cycle, Kandang, Site
|
||||
from apps.operations.models import ChickenCounting, ChickenWeight
|
||||
from apps.operations.services.chicken_counting_edge import (
|
||||
ChickenCountingEdgeError,
|
||||
aggregate_counting_by_date,
|
||||
aggregate_mortality_by_date,
|
||||
coop_for_kandang,
|
||||
location_prefix_for_kandang,
|
||||
sync_chicken_counting_for_cycle,
|
||||
)
|
||||
|
||||
DB_HISTORY_SAMPLE = [
|
||||
{"date": "2026-09-02", "location": "K1-L1", "total": 14591},
|
||||
{"date": "2026-09-02", "location": "K1-L2", "total": 14591},
|
||||
{"date": "2026-09-02", "location": "K2-L1", "total": 100},
|
||||
{"date": "2026-09-02", "location": "K2-L2", "total": 100},
|
||||
{"date": "2026-09-03", "location": "K1-L1", "total": 18736},
|
||||
{"date": "2026-09-03", "location": "K1-L2", "total": 18736},
|
||||
{"date": "2026-08-20", "location": "K1-L1", "total": 50},
|
||||
]
|
||||
|
||||
MORTALITY_HISTORY_SAMPLE = [
|
||||
{"date": "2026-09-02", "coop": "K1", "total_mortality_count": 0},
|
||||
{"date": "2026-09-02", "coop": "K2", "total_mortality_count": 10},
|
||||
{"date": "2026-09-03", "coop": "K1", "total_mortality_count": 29},
|
||||
{"date": "2026-09-03", "coop": "K2", "total_mortality_count": 29},
|
||||
]
|
||||
|
||||
|
||||
class LocationMappingTests(TestCase):
|
||||
def test_maps_kandang_to_prefix_and_coop(self):
|
||||
self.assertEqual(location_prefix_for_kandang("Kandang 1"), "K1")
|
||||
self.assertEqual(coop_for_kandang("Kandang 2"), "K2")
|
||||
|
||||
|
||||
class AggregationTests(TestCase):
|
||||
def test_sums_matching_floors_per_date(self):
|
||||
by_date = aggregate_counting_by_date(DB_HISTORY_SAMPLE, kandang_name="Kandang 1")
|
||||
self.assertEqual(by_date[date(2026, 9, 2)], 29182)
|
||||
self.assertEqual(by_date[date(2026, 9, 3)], 37472)
|
||||
self.assertNotIn(date(2026, 9, 2), aggregate_counting_by_date([], kandang_name="Kandang 1"))
|
||||
|
||||
def test_mortality_uses_matching_coop(self):
|
||||
by_date = aggregate_mortality_by_date(
|
||||
MORTALITY_HISTORY_SAMPLE, kandang_name="Kandang 1"
|
||||
)
|
||||
self.assertEqual(by_date[date(2026, 9, 2)], 0)
|
||||
self.assertEqual(by_date[date(2026, 9, 3)], 29)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=True,
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=True,
|
||||
)
|
||||
class SyncChickenCountingTests(TestCase):
|
||||
def setUp(self):
|
||||
user = User.objects.create_user(user_name="sync-user", password="secret")
|
||||
site = Site.objects.create(site_name="Sukawarna", user=user)
|
||||
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
|
||||
self.cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=33,
|
||||
start_date=date(2026, 9, 2),
|
||||
end_date=date(2026, 10, 4),
|
||||
doc_in_weight=35,
|
||||
doc_in_count=50000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
|
||||
def test_upserts_within_cycle_window(self, mock_db, mock_mort, mock_today):
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
mock_db.return_value = DB_HISTORY_SAMPLE
|
||||
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE
|
||||
|
||||
summary = sync_chicken_counting_for_cycle(self.cycle)
|
||||
|
||||
self.assertEqual(sorted(summary["dates"]), [date(2026, 9, 2), date(2026, 9, 3)])
|
||||
row_sep2 = ChickenCounting.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
|
||||
self.assertEqual(row_sep2.total_count, 29182)
|
||||
self.assertEqual(row_sep2.mortality_count, 0)
|
||||
row_sep3 = ChickenCounting.objects.get(cycle=self.cycle, date=date(2026, 9, 3))
|
||||
self.assertEqual(row_sep3.total_count, 37472)
|
||||
self.assertEqual(row_sep3.mortality_count, 29)
|
||||
self.assertFalse(
|
||||
ChickenCounting.objects.filter(cycle=self.cycle, date=date(2026, 8, 20)).exists()
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
|
||||
def test_respects_partial_enable_flags(self, mock_db, mock_mort, mock_today):
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
mock_db.return_value = DB_HISTORY_SAMPLE
|
||||
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE
|
||||
ChickenCounting.objects.create(
|
||||
cycle=self.cycle,
|
||||
date=date(2026, 9, 2),
|
||||
total_count=1,
|
||||
mortality_count=5,
|
||||
)
|
||||
|
||||
with override_settings(
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=False,
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=True,
|
||||
):
|
||||
sync_chicken_counting_for_cycle(self.cycle)
|
||||
|
||||
row = ChickenCounting.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
|
||||
self.assertEqual(row.total_count, 1)
|
||||
self.assertEqual(row.mortality_count, 0)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=True,
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=True,
|
||||
DASHBOARD_PUBLISH_HOUR=17,
|
||||
TIME_ZONE="Asia/Jakarta",
|
||||
)
|
||||
class ChickenCountingSyncApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(user_name="tester", password="secret")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
site = Site.objects.create(site_name="Sukawarna", user=self.user)
|
||||
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
|
||||
self.cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=33,
|
||||
start_date=date(2026, 9, 2),
|
||||
end_date=date(2026, 10, 4),
|
||||
doc_in_weight=35,
|
||||
doc_in_count=50000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
|
||||
def test_sync_action_upserts(self, mock_db, mock_mort, mock_today):
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
mock_db.return_value = DB_HISTORY_SAMPLE
|
||||
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/chicken-countings/sync/",
|
||||
{"cycle_id": self.cycle.pk},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(ChickenCounting.objects.filter(cycle=self.cycle).count(), 2)
|
||||
|
||||
@patch(
|
||||
"apps.operations.views.sync_chicken_counting_for_cycle",
|
||||
side_effect=ChickenCountingEdgeError("edge down"),
|
||||
)
|
||||
def test_sync_action_returns_502_on_edge_error(self, _mock_sync):
|
||||
response = self.client.post(
|
||||
"/api/v1/chicken-countings/sync/",
|
||||
{"cycle_id": self.cycle.pk},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertIn("edge down", response.data["detail"])
|
||||
|
||||
def test_counting_and_weight_visible_before_publish_time(self):
|
||||
ChickenCounting.objects.create(
|
||||
cycle=self.cycle,
|
||||
date=date(2026, 9, 4),
|
||||
total_count=100,
|
||||
mortality_count=2,
|
||||
)
|
||||
ChickenWeight.objects.create(
|
||||
cycle=self.cycle,
|
||||
date=date(2026, 9, 4),
|
||||
age=2,
|
||||
doc_weight=35,
|
||||
average_weight=40,
|
||||
chicken_count=10,
|
||||
uniformity=90,
|
||||
average_daily_gain=1,
|
||||
)
|
||||
with patch("apps.operations.services.visibility.timezone.localtime") as mock_localtime:
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
wib = ZoneInfo("Asia/Jakarta")
|
||||
mock_localtime.return_value = datetime(2026, 9, 4, 10, 0, tzinfo=wib)
|
||||
counting = self.client.get(
|
||||
"/api/v1/chicken-countings/", {"cycle_id": self.cycle.pk}
|
||||
)
|
||||
weight = self.client.get(
|
||||
"/api/v1/chicken-weights/", {"cycle_id": self.cycle.pk}
|
||||
)
|
||||
self.assertEqual(counting.status_code, 200)
|
||||
c_results = counting.data["results"] if isinstance(counting.data, dict) else counting.data
|
||||
self.assertEqual(len(c_results), 1)
|
||||
self.assertEqual(c_results[0]["date"], "2026-09-04")
|
||||
self.assertEqual(weight.status_code, 200)
|
||||
w_results = weight.data["results"] if isinstance(weight.data, dict) else weight.data
|
||||
self.assertEqual(len(w_results), 1)
|
||||
self.assertEqual(w_results[0]["date"], "2026-09-04")
|
||||
@@ -28,6 +28,10 @@ from apps.operations.serializers import (
|
||||
ManualInputSerializer,
|
||||
)
|
||||
from apps.operations.services.karung_web import KarungWebError, sync_karung_for_cycle
|
||||
from apps.operations.services.chicken_counting_edge import (
|
||||
ChickenCountingEdgeError,
|
||||
sync_chicken_counting_for_cycle,
|
||||
)
|
||||
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
|
||||
|
||||
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"})
|
||||
@@ -70,14 +74,51 @@ class ManualInputViewSet(CycleScopedViewSet):
|
||||
|
||||
|
||||
class ChickenCountingViewSet(CycleScopedViewSet):
|
||||
"""Chicken counting + mortality IoT — not subject to dashboard publish cutoff."""
|
||||
|
||||
queryset = ChickenCounting.objects.select_related("cycle").all()
|
||||
serializer_class = ChickenCountingSerializer
|
||||
|
||||
def applies_visibility_filter(self) -> bool:
|
||||
return False
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="sync")
|
||||
def sync_from_edge(self, request):
|
||||
cycle_id = request.data.get("cycle_id")
|
||||
if not cycle_id:
|
||||
return Response(
|
||||
{"detail": "cycle_id is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
cycle = Cycle.objects.select_related("kandang").get(pk=cycle_id)
|
||||
except Cycle.DoesNotExist:
|
||||
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
summary = sync_chicken_counting_for_cycle(cycle)
|
||||
except (ChickenCountingEdgeError, ValidationError) 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"],
|
||||
"dates": [d.isoformat() for d in summary["dates"]],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ChickenWeightViewSet(CycleScopedViewSet):
|
||||
"""Chicken weight — not subject to dashboard publish cutoff."""
|
||||
|
||||
queryset = ChickenWeight.objects.select_related("cycle").all()
|
||||
serializer_class = ChickenWeightSerializer
|
||||
|
||||
def applies_visibility_filter(self) -> bool:
|
||||
return False
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="latest-avg")
|
||||
def latest_average(self, request):
|
||||
cycle_id = request.query_params.get("cycle_id")
|
||||
|
||||
@@ -195,3 +195,17 @@ 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 = (
|
||||
env("CHICKEN_COUNTING_EDGE_BASE_URL", "") or ""
|
||||
).rstrip("/")
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS = float(
|
||||
env("CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS", "30") or "30"
|
||||
)
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED = env_bool(
|
||||
"CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED", True
|
||||
)
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED = env_bool(
|
||||
"CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", True
|
||||
)
|
||||
@@ -10,9 +10,15 @@ import type {
|
||||
} from '../types/api.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import KpiCard, { type KpiCardModel } from './KpiCard.tsx';
|
||||
import KpiCard, { type KpiCardModel, type KpiTrendPoint } from './KpiCard.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatNumber, formatDateRange, resolveCycleStatus } from '../utils/format.ts';
|
||||
import {
|
||||
cycleDayForDate,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
resolveCycleStatus,
|
||||
} from '../utils/format.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
|
||||
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
|
||||
@@ -91,11 +97,28 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
|
||||
const cards: KpiCardModel[] = useMemo(() => {
|
||||
const latest = kpis[0];
|
||||
const fcrTrend = chronological(kpis).map((row) => row.fcr);
|
||||
const eefTrend = chronological(kpis).map((row) => row.eef);
|
||||
const mortalityTrend = chronological(countings).map((row) => row.mortality_count);
|
||||
const startDate = selectedCycle?.start_date;
|
||||
const trendLabel = (isoDate: string): string => {
|
||||
const day = startDate ? cycleDayForDate(startDate, isoDate) : 0;
|
||||
return `Hari ${day} · ${formatDateId(isoDate)}`;
|
||||
};
|
||||
const fcrTrend: KpiTrendPoint[] = chronological(kpis).map((row) => ({
|
||||
value: row.fcr,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const eefTrend: KpiTrendPoint[] = chronological(kpis).map((row) => ({
|
||||
value: row.eef,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityTrend: KpiTrendPoint[] = chronological(countings).map((row) => ({
|
||||
value: row.mortality_count,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const mortalityToday = latest?.mortality ?? countings[0]?.mortality_count ?? null;
|
||||
const weightTrend = chronological(weights).map((row) => row.average_weight);
|
||||
const weightTrend: KpiTrendPoint[] = chronological(weights).map((row) => ({
|
||||
value: row.average_weight,
|
||||
label: trendLabel(row.date),
|
||||
}));
|
||||
const age = selectedCycle?.current_day ?? latest?.age ?? null;
|
||||
return [
|
||||
{
|
||||
|
||||
+11
-3
@@ -1,17 +1,23 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
|
||||
import { formatNumber, formatRatio } from '../utils/format.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
|
||||
export type KpiStatus = 'ok' | 'warning' | 'critical' | 'na';
|
||||
|
||||
export type KpiTrendPoint = {
|
||||
value: number;
|
||||
/** Tooltip label, e.g. ``Hari 0 · 2 Sep 2026``. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type KpiCardModel = {
|
||||
key: string;
|
||||
title: string;
|
||||
value: number | null;
|
||||
unit: string;
|
||||
icon: string;
|
||||
trend?: number[];
|
||||
trend?: KpiTrendPoint[];
|
||||
status: KpiStatus;
|
||||
targetView?: View;
|
||||
};
|
||||
@@ -46,7 +52,7 @@ const KpiCard: React.FC<Props> = ({
|
||||
const isNA = value === null;
|
||||
const styles = isNA ? statusStyles.na : statusStyles[status];
|
||||
const trendData = useMemo(
|
||||
() => (trend ?? []).map((point, index) => ({ name: index, value: point })),
|
||||
() => (trend ?? []).map((point) => ({ name: point.label, value: point.value })),
|
||||
[trend]
|
||||
);
|
||||
|
||||
@@ -94,6 +100,7 @@ const KpiCard: React.FC<Props> = ({
|
||||
{!isNA && trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trendData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<XAxis dataKey="name" hide />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
@@ -104,6 +111,7 @@ const KpiCard: React.FC<Props> = ({
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(val) => [formatNumber(Number(val ?? 0), 2), title]}
|
||||
labelFormatter={(label) => String(label ?? '')}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
</LineChart>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenCounting, KPI, ManualInput } from '../../types/api.ts';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
|
||||
import {
|
||||
addDaysIso,
|
||||
cycleDayForDate,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
resolveCycleStatus,
|
||||
todayIso,
|
||||
} from '../../utils/format.ts';
|
||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||
|
||||
@@ -18,6 +20,8 @@ type Row = {
|
||||
dateLabel: string;
|
||||
stockAwal: number;
|
||||
mati: number;
|
||||
matiIot: number;
|
||||
akurasiMati: number;
|
||||
panen: number;
|
||||
beratPanen: number;
|
||||
stockAkhir: number;
|
||||
@@ -31,6 +35,13 @@ type Row = {
|
||||
isFuture: boolean;
|
||||
};
|
||||
|
||||
function mortalityAccuracy(manual: number, iot: number): number {
|
||||
if (manual === 0 && iot === 0) return 100;
|
||||
const denom = Math.max(manual, iot);
|
||||
if (denom <= 0) return 100;
|
||||
return Math.round((Math.min(manual, iot) / denom) * 1000) / 10;
|
||||
}
|
||||
|
||||
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||
@@ -38,13 +49,15 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [searchDay, setSearchDay] = useState<string>('');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
|
||||
|
||||
const loadData = async (cycleId: number) => {
|
||||
const loadData = useCallback(async (cycleId: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -63,7 +76,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCycle) {
|
||||
@@ -74,9 +87,25 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
return;
|
||||
}
|
||||
void loadData(selectedCycle.id);
|
||||
}, [selectedCycle]);
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
// Build daily rows for day 0..currentDay, merging KPI / chicken counting / manual input data.
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!selectedCycle) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.countings.sync({ cycle_id: selectedCycle.id });
|
||||
setNotice('Sinkron chicken counting edge berhasil.');
|
||||
await loadData(selectedCycle.id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [selectedCycle, loadData]);
|
||||
|
||||
// Build daily rows through calendar today (not publish-gated current_day).
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
if (!selectedCycle) return [];
|
||||
|
||||
@@ -85,8 +114,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
|
||||
|
||||
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
|
||||
const maxDay = Math.min(selectedCycle.current_day ?? 0, lastCycleDay);
|
||||
const visibleThrough = selectedCycle.visible_through_date;
|
||||
const endDay = cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date);
|
||||
const calendarDay = cycleDayForDate(selectedCycle.start_date, todayIso());
|
||||
const maxDay = Math.min(calendarDay, lastCycleDay, endDay);
|
||||
const today = todayIso();
|
||||
|
||||
let previousStockAkhir = 0;
|
||||
let cumulativeMortality = 0;
|
||||
@@ -102,6 +133,8 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
|
||||
const stockAwal = day === 0 ? selectedCycle.doc_in_count || 0 : previousStockAkhir;
|
||||
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
|
||||
const matiIot = counting?.mortality_count ?? 0;
|
||||
const akurasiMati = mortalityAccuracy(mati, matiIot);
|
||||
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
|
||||
// Database menyimpan berat panen dalam gram; tampilkan selalu dalam kg.
|
||||
const beratPanen = (manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? 0) / 1000;
|
||||
@@ -125,6 +158,8 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
dateLabel: formatDateId(date),
|
||||
stockAwal,
|
||||
mati,
|
||||
matiIot,
|
||||
akurasiMati,
|
||||
panen,
|
||||
beratPanen,
|
||||
stockAkhir,
|
||||
@@ -137,8 +172,8 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
? kpi.harvest_weight_total / 1000
|
||||
: cumulativeHarvestWeight,
|
||||
isEdited: !!manual,
|
||||
isToday: date === visibleThrough,
|
||||
isFuture: date > visibleThrough,
|
||||
isToday: date === today,
|
||||
isFuture: date > today,
|
||||
});
|
||||
|
||||
previousStockAkhir = stockAkhir;
|
||||
@@ -207,6 +242,22 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
);
|
||||
};
|
||||
|
||||
const getAkurasiMatiBadge = (akurasi: number) => {
|
||||
const isGreen = akurasi >= 95;
|
||||
const isYellow = akurasi >= 90 && akurasi < 95;
|
||||
const colorClass = isGreen
|
||||
? 'bg-green-100 text-green-700'
|
||||
: isYellow
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700';
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{akurasi.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
@@ -216,6 +267,26 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
title="Hitung Ayam"
|
||||
icon={<i className="fa-solid fa-calculator" />}
|
||||
subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing || isClosedCycle}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Sync...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI Insight */}
|
||||
@@ -262,6 +333,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
@@ -457,6 +533,22 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-rose-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-rose-500">
|
||||
Mati IoT
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold text-rose-600 md:text-sm">
|
||||
{formatNumber(record.matiIot, 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-emerald-600">
|
||||
Akurasi Mati
|
||||
</div>
|
||||
<div className="mt-1">{getAkurasiMatiBadge(record.akurasiMati)}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-purple-50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
||||
Panen
|
||||
@@ -511,7 +603,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[1080px] lg:min-w-[1180px] w-full border-collapse table-auto">
|
||||
<table className="min-w-[1280px] lg:min-w-[1380px] w-full border-collapse table-auto">
|
||||
<thead className="bg-gray-50">
|
||||
{/* Top header row with grouped columns */}
|
||||
<tr className="border-b border-gray-300">
|
||||
@@ -528,7 +620,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
Tanggal
|
||||
</th>
|
||||
<th
|
||||
colSpan={2}
|
||||
colSpan={4}
|
||||
className="px-2 py-2 text-center text-xs font-bold uppercase text-white bg-blue-400 border-r border-gray-300"
|
||||
>
|
||||
Deplesi Daily
|
||||
@@ -566,6 +658,12 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
<th className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Mati
|
||||
</th>
|
||||
<th className="w-16 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Mati IoT
|
||||
</th>
|
||||
<th className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-blue-50 border-r border-gray-300">
|
||||
Akurasi Mati
|
||||
</th>
|
||||
<th className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 bg-purple-50 border-r border-gray-300">
|
||||
JUMLAH PANEN
|
||||
</th>
|
||||
@@ -578,7 +676,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
{filteredRecords.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={9}
|
||||
colSpan={11}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
|
||||
@@ -617,6 +715,18 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Mati IoT */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-rose-600 md:text-sm">
|
||||
{formatNumber(record.matiIot, 0)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Akurasi Mati */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
{getAkurasiMatiBadge(record.akurasiMati)}
|
||||
</td>
|
||||
|
||||
{/* JUMLAH PANEN */}
|
||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||
@@ -686,7 +796,17 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Mati:</strong> Mortalitas harian | <strong>panen:</strong> Ayam dipanen
|
||||
<strong>Mati:</strong> Mortalitas harian manual | <strong>Mati IoT:</strong>{' '}
|
||||
mortalitas dari edge API (kosong = 0)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>Akurasi Mati</strong> = (nilai kecil / nilai besar) × 100% antara Mati
|
||||
manual dan Mati IoT (keduanya 0 → 100%)
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
<strong>panen:</strong> Ayam dipanen
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DataManagementPage from '../DataManagementPage.tsx';
|
||||
import { api } from '../../../services/apiClient.ts';
|
||||
|
||||
vi.mock('../../../services/apiClient.ts', () => ({
|
||||
api: {
|
||||
@@ -11,9 +12,20 @@ vi.mock('../../../services/apiClient.ts', () => ({
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
sync: vi.fn().mockResolvedValue({ upserted: 2, dates: ['2026-09-02', '2026-09-03'] }),
|
||||
},
|
||||
manualInputs: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
list: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
cycle: 1,
|
||||
date: '2026-08-10',
|
||||
mortality_manual: 10,
|
||||
mortality_manual_total: 10,
|
||||
harvest_manual: 0,
|
||||
harvest_weight_manual: 0,
|
||||
},
|
||||
]),
|
||||
create: vi.fn(),
|
||||
},
|
||||
insights: {
|
||||
@@ -38,11 +50,13 @@ vi.mock('../../../context/FarmContext.tsx', () => ({
|
||||
kandang: 1,
|
||||
flock: 1,
|
||||
total_days: 35,
|
||||
current_day: 3,
|
||||
current_day: 1,
|
||||
visible_through_date: '2026-08-10',
|
||||
start_date: '2026-08-10',
|
||||
end_date: '2026-09-13',
|
||||
chick_in_weight: 45,
|
||||
doc_in_count: 20000,
|
||||
doc_in_weight: 45,
|
||||
status: 'active',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
@@ -50,7 +64,25 @@ vi.mock('../../../context/FarmContext.tsx', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/format.ts', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils/format.ts')>(
|
||||
'../../../utils/format.ts'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
todayIso: () => '2026-08-12',
|
||||
};
|
||||
});
|
||||
|
||||
describe('DataManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.countings.list).mockResolvedValue([]);
|
||||
vi.mocked(api.countings.sync).mockResolvedValue({
|
||||
upserted: 2,
|
||||
dates: ['2026-09-02', '2026-09-03'],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Hitung Ayam dashboard view with stats and daily rows', async () => {
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
@@ -70,4 +102,25 @@ describe('DataManagementPage', () => {
|
||||
expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Mati IoT as 0 and Akurasi Mati when counting row is missing', async () => {
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Mati IoT').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Akurasi Mati').length).toBeGreaterThan(0);
|
||||
// manual 10 vs iot 0 → 0.0%
|
||||
expect(screen.getAllByText('0.0%').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls chicken counting sync when Sync is clicked', async () => {
|
||||
render(<DataManagementPage onSettings={() => undefined} />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Hitung Ayam' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sync/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.countings.sync).toHaveBeenCalledWith({ cycle_id: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,10 @@ services:
|
||||
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:-}
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30}
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-true}
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-true}
|
||||
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-test-api-key}
|
||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n}
|
||||
@@ -116,6 +120,10 @@ services:
|
||||
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:-}
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30}
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-true}
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-true}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
||||
@@ -39,3 +39,9 @@ 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; not gated by publish cutoff)
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL=http://192.168.192.106:8090
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=true
|
||||
@@ -19,10 +19,10 @@ COPY backend/requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh /app/
|
||||
COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh docker/karung_sync_loop.sh /app/
|
||||
COPY docker/crontab /etc/cron.d/newdashboard
|
||||
|
||||
RUN chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh \
|
||||
RUN chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh /app/karung_sync_loop.sh \
|
||||
&& mkdir -p /app/data /app/media /app/staticfiles \
|
||||
&& chmod 0644 /etc/cron.d/newdashboard \
|
||||
&& crontab /etc/cron.d/newdashboard
|
||||
|
||||
+5
-1
@@ -4,5 +4,9 @@ PATH=/usr/local/bin:/usr/bin:/bin
|
||||
# IoT panel: real-time sync every 10 minutes (not gated by 17:00 publish cutoff).
|
||||
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1
|
||||
|
||||
# Daily publish at 17:00 WIB — karung first (feed use counts until 16:59), then KPI rollups.
|
||||
# Chicken counting + mortality edge sync every 10 minutes (not gated by publish cutoff).
|
||||
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1
|
||||
|
||||
# Karung-web-admin: every 30s via karung_sync_loop.sh (started by entrypoint-cron.sh).
|
||||
# Daily publish — recompute KPI rollups from latest karung + counting.
|
||||
0 17 * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1
|
||||
@@ -2,6 +2,8 @@
|
||||
set -e
|
||||
cd /app
|
||||
python manage.py migrate --noinput
|
||||
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
|
||||
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
|
||||
> /etc/environment || true
|
||||
# Cron min resolution is 1 minute; karung polls every 30s in a side loop.
|
||||
/app/karung_sync_loop.sh &
|
||||
exec cron -f
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Sub-minute karung sync — cron cannot schedule under 1 minute.
|
||||
set -eu
|
||||
cd /app
|
||||
while true; do
|
||||
/usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 || true
|
||||
sleep 30
|
||||
done
|
||||
@@ -1,89 +0,0 @@
|
||||
# Karung Sync History Backfill + Missing-as-Zero Display
|
||||
|
||||
**Date:** 2026-09-04
|
||||
**Status:** Approved (awaiting written-spec confirmation)
|
||||
**Surface:** Hitung Karung sync + summary cards
|
||||
|
||||
## Problem
|
||||
|
||||
1. **Sync only writes “today.”** `request_karung` / Sync hari ini / cron use karung-web-admin `/api/v1/cycles/active/sections` with `section=today`. Older cycle days (e.g. day 0 = cycle start) never get IoT rows even when `/api/combined` already has history for those dates.
|
||||
2. **Symptom:** Rincian harian shows IOT `0` and Total `-` for dates with manual data but no `FeedSacks` row (e.g. 2 Sep), while upstream has real in/use/out.
|
||||
3. **Frontend:** Summary cards showed `N/A` when no same-date manual; product preference is to show `0` for missing values.
|
||||
|
||||
## Goals
|
||||
|
||||
- On each Sync (UI button and cron), also upsert IoT feed-sack days for the active cycle from `/api/combined` history.
|
||||
- If upstream values change after a previous sync, the next Sync updates local `FeedSacks` for those dates.
|
||||
- Zero is a valid IoT value; do not treat `0/0/0` as “needs special retry.”
|
||||
- One backfill pass per Sync invocation (no retry loops).
|
||||
- On Hitung Karung summary cards, missing Manual/IOT values display as `0` (not `N/A`).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the 17:00 (or configured) dashboard publish cutoff.
|
||||
- Re-introducing cross-day manual fallback on summary cards (already removed).
|
||||
- Syncing closed cycles unless already in scope of the existing cron filter.
|
||||
- Building a new karung-web-admin API; use existing `/api/combined` + sections.
|
||||
|
||||
## Upstream data (confirmed)
|
||||
|
||||
`GET /api/combined` returns:
|
||||
|
||||
- `masuk.history[]`: `{ date, source, counter_value }` for `K{n} - In` and `K{n} - Out`
|
||||
- `tuang.history[]`: `{ date, source, counter_value }` for `K{n} - Use`
|
||||
- Live `masuk.today` / `tuang.today` (not required for backfill of past days)
|
||||
|
||||
`GET /api/v1/cycles/active/sections` still used for **today** (and optionally yesterday_history remains available but is insufficient alone for older cycle days).
|
||||
|
||||
## Design
|
||||
|
||||
### Sync flow (single invocation)
|
||||
|
||||
For each active cycle (cron) or the requested cycle (UI):
|
||||
|
||||
1. **Today (existing):** Call `request_karung(cycle, section="today")` as today — update/create the snapshot date returned by the sections block.
|
||||
2. **History backfill (new):** Fetch `/api/combined` once. For each calendar date `D` where `cycle.start_date <= D <= min(today, cycle.end_date)` **and** combined history has at least one row for this kandang’s sources on `D`:
|
||||
- Resolve `in_today`, `out_today`, `feed_use_today` from history (`K{index} - In|Out|Use`). Missing source for that date → `0`.
|
||||
- `update_or_create` `FeedSacks(cycle, date=D)` with those daily values.
|
||||
3. **Totals recompute:** After today + backfill writes, recompute running `in_total`, `out_total`, `feed_use_total` for **all** `FeedSacks` of that cycle ordered by `date`, `pk` (same accumulation rule as current single-day prior-row logic).
|
||||
4. **Return:** API response should include the today row (compat) plus a short summary of dates upserted from history (for debugging / UI notice optional).
|
||||
|
||||
### When to write / skip a date
|
||||
|
||||
| Situation | Behavior |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Date in cycle range and present in combined history for this kandang | Upsert daily values from history (refresh on every Sync) |
|
||||
| Date in cycle range but **no** history rows for this kandang | Do **not** create a synthetic all-zero row; leave missing |
|
||||
| Existing local row, upstream later changes | Next Sync overwrites daily fields from history, then recomputes totals |
|
||||
| Upstream reports `0` | Store `0` (valid) |
|
||||
|
||||
Rationale: missing upstream ≠ invent zeros in DB (keeps Total `-` until data exists). Frontend still shows `0` for missing display cells.
|
||||
|
||||
### Frontend (Hitung Karung)
|
||||
|
||||
- Summary cards (`FeedStats`): when Manual or IOT daily/total value is `null`/missing for the display date, show **`0`**, not `N/A`.
|
||||
- Keep same-date manual pairing (no fallback to an older manual day).
|
||||
- Daily table already uses `0` for missing Manual/IOT; optional consistency: Total `-` may remain `-` when no IoT row (indicates no synced row) — **prefer keep `-` for Total when no row** so operators can see “not synced yet” vs synced zero. Daily Manual/IOT columns stay `0`.
|
||||
|
||||
### API / callers
|
||||
|
||||
- `POST /api/v1/karungs/request/` continues to accept `section` for today path; after today sync, always run history backfill for that cycle (section choice does not disable backfill).
|
||||
- Cron `sync_karung_from_web_admin`: same combined flow per active cycle.
|
||||
- Prefer a single service entrypoint e.g. `sync_karung_for_cycle(cycle, *, section="today")` used by view + command.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: parse combined history → per-date in/use/out for a kandang; skip dates with no history; upsert updates existing; totals recomputed across days.
|
||||
- Unit/API: request sync creates missing cycle-start day from history while refreshing today.
|
||||
- Frontend: FeedStats / page test expects `0` instead of `N/A` when latest IoT day has no matching manual.
|
||||
|
||||
## Risks
|
||||
|
||||
- Combined payload size / timeout: one fetch per cycle sync (acceptable at current history sizes); reuse one client call per cycle.
|
||||
- Double-count totals if recompute is wrong: always full recompute from daily fields in date order, do not add “prior + today” on top of stale totals.
|
||||
- Timezone: use Asia/Jakarta “today” consistently with existing dashboard settings.
|
||||
|
||||
## Out of scope follow-ups
|
||||
|
||||
- Backfilling closed cycles.
|
||||
- Exposing a separate “Sync history only” button.
|
||||
@@ -215,6 +215,8 @@ export const api = {
|
||||
update: (id: number, body: Partial<CountingWrite>) =>
|
||||
patch<ChickenCounting>(`/chicken-countings/${id}/`, body),
|
||||
remove: (id: number) => del(`/chicken-countings/${id}/`),
|
||||
sync: (body: { cycle_id: number }) =>
|
||||
post<{ upserted: number; dates: string[] }>('/chicken-countings/sync/', body),
|
||||
},
|
||||
weights: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
|
||||
Reference in new issue
Block a user