update sync data from site dashboard and testing closing cycles

This commit is contained in:
Alberto-Audrix committed 2026-09-15 16:52:41 +07:00
1 parent 36639d8c38
commit f49e72592c
42 files changed
+1941 -663

No files matched your search

+3 -5
View File
@@ -22,11 +22,9 @@ BOOTSTRAP_ADMIN_PASSWORD=
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
# City-edge syncs off at HQ (mirror via active-sites instead)
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=false
# HQ mirror pull from farm pusat export APIs (ops + IoT)
ACTIVE_SITE_MIRROR_SYNC_ENABLED=true
ACTIVE_SITE_MIRROR_CLOSED_LIMIT=3
DASHBOARD_PUBLISH_HOUR=17
DASHBOARD_PUBLISH_MINUTE=0
+3 -1
View File
@@ -27,7 +27,9 @@ BUH farm visibility is the union of those lists for **confirmed** managed GMs (r
## Active site registry
`GET/POST /api/v1/active-sites/` — register farm-location dashboard APIs.
`POST /api/v1/active-sites/{id}/sync/` and `POST /api/v1/active-sites/sync-all/` — stubs (`501`) until mirror pull is built.
`POST /api/v1/active-sites/{id}/sync/` and `POST /api/v1/active-sites/sync-all/` — pull farm `pusat/export` + `pusat/export/iot` into the HQ mirror (ops + IoT).
Cron: `manage.py sync_active_sites_from_farm` every 30 minutes when `ACTIVE_SITE_MIRROR_SYNC_ENABLED=true`.
If farm rotated `SITE_API_KEY`, re-register the site (or update `ActiveSite.api_key`) so pull auth succeeds.
## Setup
@@ -0,0 +1,45 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0019_site_id_slug"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="source_kandang_id",
field=models.BigIntegerField(
blank=True,
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
null=True,
),
),
migrations.AddField(
model_name="flock",
name="source_flock_id",
field=models.BigIntegerField(
blank=True,
help_text="flock_id on the farm-location dashboard-cpsp deployment",
null=True,
),
),
migrations.AddConstraint(
model_name="kandang",
constraint=models.UniqueConstraint(
condition=models.Q(("source_kandang_id__isnull", False)),
fields=("site", "source_kandang_id"),
name="uniq_kandang_site_source",
),
),
migrations.AddConstraint(
model_name="flock",
constraint=models.UniqueConstraint(
condition=models.Q(("source_flock_id__isnull", False)),
fields=("kandang", "source_flock_id"),
name="uniq_flock_kandang_source",
),
),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0020_kandang_flock_source_ids"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_iot",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_accuracy",
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_sync_error",
field=models.TextField(blank=True, null=True),
),
]
+27
View File
@@ -68,12 +68,24 @@ class Kandang(models.Model):
kandang_name = models.CharField(max_length=30)
feed_in_button_urls = models.JSONField(default=list, blank=True)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
source_kandang_id = models.BigIntegerField(
null=True,
blank=True,
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
constraints = [
models.UniqueConstraint(
fields=["site", "source_kandang_id"],
condition=models.Q(source_kandang_id__isnull=False),
name="uniq_kandang_site_source",
),
]
def __str__(self) -> str:
return self.kandang_name
@@ -87,12 +99,24 @@ class Flock(models.Model):
on_delete=models.CASCADE,
related_name="flocks",
)
source_flock_id = models.BigIntegerField(
null=True,
blank=True,
help_text="flock_id on the farm-location dashboard-cpsp deployment",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "flock"
ordering = ["flock_name"]
constraints = [
models.UniqueConstraint(
fields=["kandang", "source_flock_id"],
condition=models.Q(source_flock_id__isnull=False),
name="uniq_flock_kandang_source",
),
]
def __str__(self) -> str:
return self.flock_name
@@ -116,6 +140,9 @@ class Cycle(models.Model):
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
feed_initial_balance_iot = models.IntegerField(null=True, blank=True)
feed_initial_balance_accuracy = models.FloatField(null=True, blank=True)
feed_initial_balance_sync_error = models.TextField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
+17 -3
View File
@@ -38,10 +38,11 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name",
"feed_in_button_urls",
"site",
"source_kandang_id",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
read_only_fields = ["id", "source_kandang_id", "created_at", "updated_at"]
def validate_feed_in_button_urls(self, value):
cleaned = []
@@ -77,8 +78,15 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
fields = [
"id",
"flock_name",
"kandang",
"source_flock_id",
"created_at",
"updated_at",
]
read_only_fields = ["id", "source_flock_id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
@@ -105,6 +113,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"doc_in_count",
"feed_initial_balance",
"feed_initial_balance_date",
"feed_initial_balance_iot",
"feed_initial_balance_accuracy",
"feed_initial_balance_sync_error",
"status",
"active_site",
"source_cycle_id",
@@ -121,6 +132,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"feed_initial_balance_iot",
"feed_initial_balance_accuracy",
"feed_initial_balance_sync_error",
"status",
"active_site",
"source_cycle_id",
+18 -119
View File
@@ -6,7 +6,6 @@ from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import FeedSacks, ManualInput
from apps.sync.models import ActiveSite
@@ -264,7 +263,7 @@ class FarmAPITests(TestCase):
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 2)
def test_set_initial_balance_does_not_create_feed_sack_row(self):
def test_initial_balance_write_endpoints_removed(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
@@ -275,123 +274,21 @@ class FarmAPITests(TestCase):
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
post = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 120)
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=1),
in_today=4,
in_total=4,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
compare = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
self.assertEqual(post.status_code, 404)
self.assertEqual(compare.status_code, 404)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 4)
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self):
def test_cycle_api_exposes_mirrored_initial_balance_iot_fields(self):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=2),
in_today=6,
in_total=6,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
first = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(first.status_code, 200, first.data)
second = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 80)
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 6)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 115)
self.assertEqual(response.data["accuracy"], 95.8)
mock_pull.assert_called_once_with(cycle, start)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
start = date(2026, 8, 27)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
@@ -400,15 +297,17 @@ class FarmAPITests(TestCase):
doc_in_weight=40,
doc_in_count=9000,
feed_initial_balance=120,
feed_initial_balance_date=start,
feed_initial_balance_iot=115,
feed_initial_balance_accuracy=95.8,
feed_initial_balance_sync_error=None,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 112)
self.assertEqual(response.data["accuracy"], 93.3)
mock_pull.assert_called_once_with(cycle, start)
self.assertEqual(response.data["feed_initial_balance"], 120)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
self.assertEqual(response.data["feed_initial_balance_iot"], 115)
self.assertEqual(response.data["feed_initial_balance_accuracy"], 95.8)
self.assertIsNone(response.data["feed_initial_balance_sync_error"])
+24 -130
View File
@@ -15,14 +15,6 @@ from apps.accounts.scoping import (
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.site_close import SiteClosePushError, push_close_decision
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.sync.models import ActiveSite
@@ -61,6 +53,30 @@ def _upsert_mirror_cycle_from_ingest(active_site: ActiveSite, payload: dict) ->
# Prefer ingest site_name when it does not collide.
site.site_name = site_name
site.save(update_fields=["site_name", "updated_at"])
source_kandang_raw = payload.get("source_kandang_id", payload.get("kandang_id"))
source_kandang_id = None
if source_kandang_raw not in (None, ""):
try:
source_kandang_id = int(source_kandang_raw)
except (TypeError, ValueError) as exc:
raise ValidationError("source_kandang_id must be an integer.") from exc
kandang = None
if source_kandang_id is not None:
kandang = Kandang.objects.filter(
site=site, source_kandang_id=source_kandang_id
).first()
if kandang is None:
kandang = Kandang.objects.create(
site=site,
kandang_name=kandang_name,
source_kandang_id=source_kandang_id,
)
elif kandang.kandang_name != kandang_name:
kandang.kandang_name = kandang_name
kandang.save(update_fields=["kandang_name", "updated_at"])
if kandang is None:
kandang, _ = Kandang.objects.get_or_create(
site=site,
kandang_name=kandang_name,
@@ -266,125 +282,3 @@ class CycleViewSet(viewsets.ModelViewSet):
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(data)
@action(detail=True, methods=["get"], url_path="initial-balance-compare")
def compare_initial_balance(self, request, pk=None):
"""Pull IoT masuk for a date and compare against the cycle saldo awal."""
cycle = self.get_object()
date_str = request.query_params.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
manual_raw = request.query_params.get("manual")
if manual_raw is None:
feed_in_manual = cycle.feed_initial_balance
else:
try:
feed_in_manual = int(manual_raw)
except (TypeError, ValueError):
return Response(
{"detail": "manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error,
}
)
@action(detail=True, methods=["post"], url_path="initial-balance")
def set_initial_balance(self, request, pk=None):
"""Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation). Opening balance is kept on
the cycle for saldo only and is not written into daily FeedSacks rows.
Pulls the IoT-detected "in" for that date from karung-web-admin
``/api/combined`` and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
date_str = request.data.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
feed_in_manual = int(request.data.get("feed_in_manual"))
except (TypeError, ValueError):
return Response(
{"detail": "feed_in_manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
if feed_in_manual < 0:
return Response(
{"detail": "feed_in_manual cannot be negative."},
status=status.HTTP_400_BAD_REQUEST,
)
previous_date = cycle.feed_initial_balance_date
previous_balance = cycle.feed_initial_balance
cycle.feed_initial_balance = feed_in_manual
cycle.feed_initial_balance_date = balance_date
cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
# Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
cleanup_opening_balance_feed_rows(
cycle,
previous_date=previous_date,
previous_balance=previous_balance,
balance_date=balance_date,
balance_amount=feed_in_manual,
)
recompute_feed_sack_in_totals(cycle)
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)
@@ -0,0 +1,58 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.sync.services.mirror_pull import STATUS_OK, sync_all_active_sites
class Command(BaseCommand):
help = "Pull ops + IoT exports from registered ActiveSite farm APIs into the HQ mirror"
def add_arguments(self, parser):
parser.add_argument(
"--closed-limit",
type=int,
default=None,
help="Last N closed cycles per kandang (default from settings)",
)
parser.add_argument(
"--page-size",
type=int,
default=None,
help="Export page size (default from settings)",
)
def handle(self, *args, **options):
if not settings.ACTIVE_SITE_MIRROR_SYNC_ENABLED:
self.stdout.write("ACTIVE_SITE_MIRROR_SYNC_ENABLED=false; skipping")
return
closed_limit = options["closed_limit"]
if closed_limit is None:
closed_limit = settings.ACTIVE_SITE_MIRROR_CLOSED_LIMIT
page_size = options["page_size"]
if page_size is None:
page_size = settings.ACTIVE_SITE_MIRROR_SYNC_PAGE_SIZE
summaries = sync_all_active_sites(
closed_limit=closed_limit, page_size=page_size
)
if not summaries:
self.stdout.write("No active sites with api_base_url/api_key to sync.")
return
ok = 0
for summary in summaries:
line = (
f"active_site={summary.active_site_id} status={summary.status} "
f"cycles={summary.selected_cycles} iot={summary.iot_panels} "
f"— {summary.message}"
)
if summary.status == STATUS_OK:
ok += 1
self.stdout.write(self.style.SUCCESS(line))
else:
self.stderr.write(line)
self.stdout.write(
self.style.SUCCESS(f"Mirror sync done: {ok}/{len(summaries)} ok")
)
@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0010_delete_reportsnapshot"),
("farms", "0020_kandang_flock_source_ids"),
]
operations = [
migrations.AddConstraint(
model_name="aiinsight",
constraint=models.UniqueConstraint(
fields=("cycle", "date", "section", "session"),
name="uniq_ai_insight_cycle_date_section_session",
),
),
]
+6
View File
@@ -39,6 +39,12 @@ class AIInsight(models.Model):
class Meta:
db_table = "ai_insight"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(
fields=["cycle", "date", "section", "session"],
name="uniq_ai_insight_cycle_date_section_session",
),
]
class FeedSacks(models.Model):
+20
View File
@@ -83,6 +83,26 @@ class OperationsAPITests(TestCase):
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["date"], date.today().isoformat())
def test_panel_iot_active_cycle_null_end_date_does_not_500(self):
"""Active cycles have end_date=None; filtering must not raise ValueError."""
self.cycle.end_date = None
self.cycle.status = Cycle.STATUS_ACTIVE
self.cycle.save(update_fields=["end_date", "status", "updated_at"])
IotPanel.objects.create(
flock=self.flock,
date=date.today(),
average_temperature=27.0,
)
response = self.client.get(
"/api/v1/panel-iot/dates/",
{"cycle_id": self.cycle.pk, "flock_id": self.flock.pk},
)
self.assertEqual(response.status_code, 200, response.data)
iso_dates = [
d.isoformat() if hasattr(d, "isoformat") else str(d) for d in response.data
]
self.assertIn(date.today().isoformat(), iso_dates)
def test_panel_iot_filters_by_flock(self):
flock_b = Flock.objects.create(flock_name="Flock B", kandang=self.kandang)
row = IotPanel.objects.create(
+3 -1
View File
@@ -206,10 +206,12 @@ class IotPanelViewSet(viewsets.ModelViewSet):
cycle = visible_cycles.get(pk=cycle_id)
except Cycle.DoesNotExist:
return qs.none()
# Active cycles have end_date=None; date__lte=None raises ValueError (500).
end = cycle.end_date or cycle.soft_end_date()
qs = qs.filter(
flock__kandang_id=cycle.kandang_id,
date__gte=cycle.start_date,
date__lte=cycle.end_date,
date__lte=end,
)
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])
+1
View File
@@ -0,0 +1 @@
"""Active-site mirror pull services."""
@@ -0,0 +1,198 @@
"""HTTP client for farm-location pusat export APIs."""
from __future__ import annotations
from typing import Any, Callable, Iterator
from urllib.parse import urljoin
import httpx
from apps.sync.models import ActiveSite
DEFAULT_PAGE_SIZE = 500
DEFAULT_TIMEOUT = 60.0
OPS_SERIES_KEYS = (
"kpis",
"manual_inputs",
"feed_sacks",
"chicken_countings",
"chicken_weights",
"ai_insights",
)
class FarmExportError(Exception):
def __init__(self, message: str, *, status_code: int | None = None):
self.message = message
self.status_code = status_code
super().__init__(message)
class FarmExportClient:
def __init__(
self,
active_site: ActiveSite,
*,
timeout: float = DEFAULT_TIMEOUT,
transport: httpx.BaseTransport | None = None,
):
base = (active_site.api_base_url or "").strip().rstrip("/") + "/"
if not base or base == "/":
raise FarmExportError("ActiveSite.api_base_url is required.")
if not (active_site.api_key or "").strip():
raise FarmExportError("ActiveSite.api_key is required.")
self._base = base
self._headers = {
"X-API-Key": active_site.api_key.strip(),
"Accept": "application/json",
}
self._timeout = timeout
self._transport = transport
def _url(self, path: str) -> str:
return urljoin(self._base, path.lstrip("/"))
def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
cleaned = {
key: value
for key, value in (params or {}).items()
if value is not None and value != ""
}
try:
with httpx.Client(
timeout=self._timeout, transport=self._transport
) as client:
response = client.get(
self._url(path), headers=self._headers, params=cleaned
)
except httpx.HTTPError as exc:
raise FarmExportError(f"Network error calling farm export: {exc}") from exc
if response.status_code == 401:
raise FarmExportError("Farm rejected API key (401).", status_code=401)
if response.status_code == 403:
raise FarmExportError("Farm denied access (403).", status_code=403)
if response.status_code >= 400:
detail = (response.text or "")[:300]
raise FarmExportError(
f"Farm export failed ({response.status_code}): {detail}",
status_code=response.status_code,
)
try:
payload = response.json()
except ValueError as exc:
raise FarmExportError("Farm export returned non-JSON body.") from exc
if not isinstance(payload, dict):
raise FarmExportError("Farm export JSON must be an object.")
return payload
def fetch_ops_export(self, **params: Any) -> dict[str, Any]:
return self._get("pusat/export/", params)
def fetch_iot_export(self, **params: Any) -> dict[str, Any]:
return self._get("pusat/export/iot/", params)
def iter_paginated_list(
self,
fetch: Callable[..., dict[str, Any]],
*,
list_key: str,
page_size: int = DEFAULT_PAGE_SIZE,
**params: Any,
) -> Iterator[list[dict[str, Any]]]:
"""Yield result pages until page * page_size >= count for list_key."""
page = 1
while True:
payload = fetch(page=page, page_size=page_size, **params)
data = payload.get("data") or {}
block = data.get(list_key) or {}
results = block.get("results") or []
if not isinstance(results, list):
raise FarmExportError(f"data.{list_key}.results must be a list.")
yield [row for row in results if isinstance(row, dict)]
count = int(block.get("count") or 0)
size = int(block.get("page_size") or page_size)
current = int(block.get("page") or page)
if size <= 0:
break
if current * size >= count or not results:
break
page = current + 1
def collect_ops_series(
self,
*,
site_id: str,
cycle_id: int | None = None,
date_from: str | None = None,
date_to: str | None = None,
page_size: int = DEFAULT_PAGE_SIZE,
) -> dict[str, list[dict[str, Any]]]:
"""Fetch all pages for every ops series key (shared page cursor)."""
collected = {key: [] for key in OPS_SERIES_KEYS}
page = 1
while True:
params: dict[str, Any] = {
"site_id": site_id,
"page": page,
"page_size": page_size,
}
if cycle_id is not None:
params["cycle_id"] = cycle_id
if date_from is not None:
params["from"] = date_from
if date_to is not None:
params["to"] = date_to
payload = self.fetch_ops_export(**params)
data = payload.get("data") or {}
any_more = False
for key in OPS_SERIES_KEYS:
block = data.get(key) or {}
results = block.get("results") or []
if isinstance(results, list):
collected[key].extend(
row for row in results if isinstance(row, dict)
)
count = int(block.get("count") or 0)
size = int(block.get("page_size") or page_size)
current = int(block.get("page") or page)
if size > 0 and current * size < count and results:
any_more = True
if not any_more:
break
page += 1
return collected
def collect_iot_export(
self,
*,
site_id: str,
date_from: str,
date_to: str,
page_size: int = DEFAULT_PAGE_SIZE,
flock_id: int | None = None,
kandang_id: int | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return structure envelope (page 1) plus all iot_panels rows."""
params: dict[str, Any] = {
"site_id": site_id,
"from": date_from,
"to": date_to,
}
if flock_id is not None:
params["flock_id"] = flock_id
if kandang_id is not None:
params["kandang_id"] = kandang_id
first = self.fetch_iot_export(page=1, page_size=page_size, **params)
panels: list[dict[str, Any]] = []
for page_rows in self.iter_paginated_list(
self.fetch_iot_export,
list_key="iot_panels",
page_size=page_size,
**params,
):
panels.extend(page_rows)
return first, panels
+733
View File
@@ -0,0 +1,733 @@
"""Pull farm ops + IoT exports and upsert into the HQ mirror."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from django.db import transaction
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_datetime
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.sync.models import ActiveSite
from apps.sync.services.farm_export_client import (
DEFAULT_PAGE_SIZE,
FarmExportClient,
FarmExportError,
)
logger = logging.getLogger(__name__)
CLOSED_LIMIT_PER_KANDANG = 3
STATUS_OK = "ok"
STATUS_ERROR = "error"
@dataclass
class MirrorSyncSummary:
active_site_id: int
status: str = STATUS_OK
message: str = ""
kandangs: int = 0
cycles: int = 0
flocks: int = 0
selected_cycles: int = 0
series_rows: dict[str, int] = field(default_factory=dict)
iot_panels: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"active_site_id": self.active_site_id,
"status": self.status,
"message": self.message,
"kandangs": self.kandangs,
"cycles": self.cycles,
"flocks": self.flocks,
"selected_cycles": self.selected_cycles,
"series_rows": self.series_rows,
"iot_panels": self.iot_panels,
}
def _as_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _as_float(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _as_date(value: Any) -> date | None:
if value is None or value == "":
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
return parse_date(str(value)[:10])
def _as_datetime(value: Any):
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
return parse_datetime(str(value))
def _float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def select_source_cycle_ids(
structure_cycles: list[dict[str, Any]],
*,
closed_limit: int = CLOSED_LIMIT_PER_KANDANG,
) -> list[int]:
"""Pick farm cycle ids: all active/pending_close + last N closed per kandang."""
by_kandang: dict[int, list[dict[str, Any]]] = {}
selected: list[int] = []
for raw in structure_cycles:
source_id = _as_int(raw.get("id") or raw.get("cycle_id"))
if source_id is None:
continue
status = str(raw.get("status") or "").strip().lower()
kandang_id = _as_int(raw.get("kandang")) or 0
if status in (Cycle.STATUS_ACTIVE, Cycle.STATUS_PENDING_CLOSE):
selected.append(source_id)
continue
if status == Cycle.STATUS_CLOSED:
by_kandang.setdefault(kandang_id, []).append(raw)
for rows in by_kandang.values():
rows.sort(
key=lambda r: (_as_date(r.get("end_date")) or date.min, _as_int(r.get("id")) or 0),
reverse=True,
)
for raw in rows[:closed_limit]:
source_id = _as_int(raw.get("id") or raw.get("cycle_id"))
if source_id is not None:
selected.append(source_id)
# Stable unique order
seen: set[int] = set()
ordered: list[int] = []
for cid in selected:
if cid not in seen:
seen.add(cid)
ordered.append(cid)
return ordered
def _upsert_kandang(site: Site, raw: dict[str, Any]) -> Kandang:
source_id = _as_int(raw.get("kandang_id") or raw.get("id"))
name = str(raw.get("kandang_name") or "Kandang")[:30]
feed_urls = raw.get("feed_in_button_urls")
if not isinstance(feed_urls, list):
feed_urls = []
if source_id is not None:
kandang = Kandang.objects.filter(site=site, source_kandang_id=source_id).first()
if kandang is None:
kandang = Kandang(
site=site,
source_kandang_id=source_id,
kandang_name=name,
feed_in_button_urls=feed_urls,
)
kandang.save()
else:
kandang.kandang_name = name
kandang.feed_in_button_urls = feed_urls
kandang.save(
update_fields=["kandang_name", "feed_in_button_urls", "updated_at"]
)
return kandang
kandang, _ = Kandang.objects.get_or_create(
site=site, kandang_name=name, defaults={"feed_in_button_urls": feed_urls}
)
return kandang
def _upsert_cycle(
active_site: ActiveSite,
kandang: Kandang,
raw: dict[str, Any],
) -> Cycle | None:
source_id = _as_int(raw.get("id") or raw.get("cycle_id"))
start = _as_date(raw.get("start_date"))
if source_id is None or start is None:
return None
doc_in_weight = _int(raw.get("chick_in_weight", raw.get("doc_in_weight")), 0)
doc_in_count = _int(raw.get("doc_in_count"), 0)
end = _as_date(raw.get("end_date"))
status = str(raw.get("status") or Cycle.STATUS_ACTIVE).strip().lower()
if status not in {
Cycle.STATUS_ACTIVE,
Cycle.STATUS_PENDING_CLOSE,
Cycle.STATUS_CLOSED,
}:
status = Cycle.STATUS_ACTIVE
defaults = {
"kandang": kandang,
"start_date": start,
"end_date": end,
"doc_in_weight": doc_in_weight,
"doc_in_count": doc_in_count,
"total_days": max(_int(raw.get("total_days"), 1), 1),
"feed_initial_balance": _int(raw.get("feed_initial_balance"), 0),
"feed_initial_balance_date": _as_date(raw.get("feed_initial_balance_date")),
"feed_initial_balance_iot": _as_int(raw.get("feed_initial_balance_iot")),
"feed_initial_balance_accuracy": _as_float(
raw.get("feed_initial_balance_accuracy")
),
"feed_initial_balance_sync_error": (
str(raw.get("feed_initial_balance_sync_error"))
if raw.get("feed_initial_balance_sync_error") not in (None, "")
else None
),
"status": status,
"proposed_end_date": _as_date(raw.get("proposed_end_date")),
}
cycle = Cycle.objects.filter(
active_site=active_site, source_cycle_id=source_id
).first()
if cycle is None:
cycle = Cycle(active_site=active_site, source_cycle_id=source_id, **defaults)
cycle.save()
else:
for key, value in defaults.items():
setattr(cycle, key, value)
cycle.save()
return cycle
def _upsert_flock(kandang: Kandang, raw: dict[str, Any]) -> Flock | None:
source_id = _as_int(raw.get("flock_id") or raw.get("id"))
name = str(raw.get("flock_name") or "Flock")[:30]
if source_id is None:
flock, _ = Flock.objects.get_or_create(
kandang=kandang, flock_name=name, defaults={}
)
return flock
flock = Flock.objects.filter(kandang=kandang, source_flock_id=source_id).first()
if flock is None:
flock = Flock(kandang=kandang, source_flock_id=source_id, flock_name=name)
flock.save()
else:
flock.flock_name = name
flock.save(update_fields=["flock_name", "updated_at"])
return flock
def _upsert_feed_sacks(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
count = 0
for raw in rows:
row_date = _as_date(raw.get("date"))
if row_date is None:
continue
FeedSacks.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults={
"in_today": _int(raw.get("in_today")),
"out_today": _int(raw.get("out_today")),
"in_total": _int(raw.get("in_total")),
"out_total": _int(raw.get("out_total")),
"feed_use_today": _int(raw.get("feed_use_today")),
"feed_use_total": _int(raw.get("feed_use_total")),
},
)
count += 1
return count
def _upsert_chicken_countings(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
count = 0
for raw in rows:
row_date = _as_date(raw.get("date"))
if row_date is None:
continue
ChickenCounting.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults={
"total_count": _int(raw.get("total_count")),
"mortality_count": _int(raw.get("mortality_count")),
},
)
count += 1
return count
def _upsert_chicken_weights(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
count = 0
for raw in rows:
row_date = _as_date(raw.get("date"))
if row_date is None:
continue
ChickenWeight.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults={
"age": _int(raw.get("age")),
"doc_weight": _int(raw.get("doc_weight")),
"average_weight": _float(raw.get("average_weight")),
"chicken_count": _int(raw.get("chicken_count")),
"uniformity": _float(raw.get("uniformity")),
"average_daily_gain": _float(raw.get("average_daily_gain")),
},
)
count += 1
return count
def _upsert_manual_inputs(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
count = 0
for raw in rows:
row_date = _as_date(raw.get("date"))
if row_date is None:
continue
ManualInput.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults={
"age_manual": _int(raw.get("age_manual")),
"mortality_manual": _int(raw.get("mortality_manual")),
"mortality_manual_total": _int(raw.get("mortality_manual_total")),
"feed_in_manual": _int(raw.get("feed_in_manual")),
"feed_use_manual": _int(raw.get("feed_use_manual")),
"feed_in_manual_total": _int(raw.get("feed_in_manual_total")),
"feed_use_manual_total": _int(raw.get("feed_use_manual_total")),
"feed_out_manual": _int(raw.get("feed_out_manual")),
"feed_out_manual_total": _int(raw.get("feed_out_manual_total")),
"harvest_manual": _int(raw.get("harvest_manual")),
"harvest_manual_total": _int(raw.get("harvest_manual_total")),
"harvest_weight_manual": _float(raw.get("harvest_weight_manual")),
"harvest_weight_manual_total": _float(
raw.get("harvest_weight_manual_total")
),
"manual_weight": _float(raw.get("manual_weight")),
"average_harvest_day_manual": _float(
raw.get("average_harvest_day_manual")
),
"average_harvest_weight_manual": _float(
raw.get("average_harvest_weight_manual")
),
"fcr_manual": _float(raw.get("fcr_manual")),
"eef_manual": _float(raw.get("eef_manual")),
},
)
count += 1
return count
def _ensure_related_for_kpi(cycle: Cycle, row_date: date) -> tuple:
cc, _ = ChickenCounting.objects.get_or_create(
cycle=cycle, date=row_date, defaults={}
)
cw, _ = ChickenWeight.objects.get_or_create(
cycle=cycle,
date=row_date,
defaults={
"age": 0,
"doc_weight": cycle.doc_in_weight,
"average_weight": 0.0,
"chicken_count": 0,
"uniformity": 0.0,
"average_daily_gain": 0.0,
},
)
fs, _ = FeedSacks.objects.get_or_create(cycle=cycle, date=row_date, defaults={})
manual, _ = ManualInput.objects.get_or_create(
cycle=cycle, date=row_date, defaults={}
)
return cc, cw, fs, manual
def _upsert_kpis(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
count = 0
for raw in rows:
row_date = _as_date(raw.get("date"))
if row_date is None:
continue
cc, cw, fs, manual = _ensure_related_for_kpi(cycle, row_date)
KPI.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults={
"cc": cc,
"cw": cw,
"fs": fs,
"manual": manual,
"age": _int(raw.get("age")),
"mortality": _int(raw.get("mortality")),
"mortality_total": _int(raw.get("mortality_total")),
"feed": _int(raw.get("feed")),
"feed_total": _int(raw.get("feed_total")),
"harvest": _int(raw.get("harvest")),
"harvest_total": _int(raw.get("harvest_total")),
"harvest_weight": _float(raw.get("harvest_weight")),
"harvest_weight_total": _float(raw.get("harvest_weight_total")),
"chicken_life": _int(raw.get("chicken_life")),
"chicken_life_percentage": _float(raw.get("chicken_life_percentage")),
"iot_weight": _float(raw.get("iot_weight")),
"average_harvest_day": _float(raw.get("average_harvest_day")),
"average_harvest_weight": _float(raw.get("average_harvest_weight")),
"fcr": _float(raw.get("fcr")),
"eef": _float(raw.get("eef")),
},
)
count += 1
return count
def _upsert_ai_insights(cycle: Cycle, rows: list[dict[str, Any]]) -> int:
count = 0
for raw in rows:
row_date = _as_date(raw.get("date"))
section = str(raw.get("section") or "")[:100]
session = str(raw.get("session") or "")[:100]
if row_date is None:
continue
AIInsight.objects.update_or_create(
cycle=cycle,
date=row_date,
section=section,
session=session,
defaults={
"insight_text": str(raw.get("insight_text") or ""),
"alert": str(raw.get("alert") or "")[:100],
},
)
count += 1
return count
def _upsert_iot_panels(
flock_by_source: dict[int, Flock], rows: list[dict[str, Any]]
) -> int:
count = 0
for raw in rows:
source_flock = _as_int(raw.get("flock_id") or raw.get("flock"))
flock = flock_by_source.get(source_flock) if source_flock is not None else None
if flock is None:
continue
ts = _as_datetime(raw.get("timestamp"))
row_date = _as_date(raw.get("date"))
if row_date is None and ts is not None:
row_date = timezone.localtime(ts).date() if timezone.is_aware(ts) else ts.date()
if row_date is None or ts is None:
continue
IotPanel.objects.update_or_create(
flock=flock,
timestamp=ts,
defaults={
"date": row_date,
"wind_speed": _float(raw.get("wind_speed")),
"humidity": _float(raw.get("humidity")),
"water_total": _float(raw.get("water_total")),
"average_temperature": _float(raw.get("average_temperature")),
"experience_temperature": _float(raw.get("experience_temperature")),
},
)
count += 1
return count
def _mark_active_site(active_site: ActiveSite, summary: MirrorSyncSummary) -> None:
active_site.last_synced_at = timezone.now()
active_site.last_sync_status = summary.status
active_site.last_sync_message = summary.message[:2000]
active_site.save(
update_fields=[
"last_synced_at",
"last_sync_status",
"last_sync_message",
"updated_at",
]
)
@transaction.atomic
def sync_active_site(
active_site: ActiveSite,
*,
client: FarmExportClient | None = None,
closed_limit: int = CLOSED_LIMIT_PER_KANDANG,
page_size: int = DEFAULT_PAGE_SIZE,
) -> MirrorSyncSummary:
summary = MirrorSyncSummary(active_site_id=active_site.pk)
site_id = str(active_site.source_site_id or "").strip()
if not site_id:
summary.status = STATUS_ERROR
summary.message = "ActiveSite.source_site_id is required."
_mark_active_site(active_site, summary)
return summary
try:
site = ensure_hq_site_for_active_site(active_site)
export_client = client or FarmExportClient(active_site)
ops_structure = export_client.fetch_ops_export(
site_id=site_id, page=1, page_size=page_size
)
sites_tree = ops_structure.get("sites") or []
if not sites_tree:
raise FarmExportError("Ops export returned empty sites structure.")
structure_cycles: list[dict[str, Any]] = []
cycle_map: dict[int, Cycle] = {}
kandang_by_source: dict[int, Kandang] = {}
for site_node in sites_tree:
for kandang_raw in site_node.get("kandangs") or []:
if not isinstance(kandang_raw, dict):
continue
kandang = _upsert_kandang(site, kandang_raw)
summary.kandangs += 1
source_kandang = _as_int(
kandang_raw.get("kandang_id") or kandang_raw.get("id")
)
if source_kandang is not None:
kandang_by_source[source_kandang] = kandang
for cycle_raw in kandang_raw.get("cycles") or []:
if not isinstance(cycle_raw, dict):
continue
structure_cycles.append(cycle_raw)
cycle = _upsert_cycle(active_site, kandang, cycle_raw)
if cycle is None:
continue
summary.cycles += 1
if cycle.source_cycle_id is not None:
cycle_map[int(cycle.source_cycle_id)] = cycle
selected_ids = select_source_cycle_ids(
structure_cycles, closed_limit=closed_limit
)
summary.selected_cycles = len(selected_ids)
series_totals = {
"kpis": 0,
"manual_inputs": 0,
"feed_sacks": 0,
"chicken_countings": 0,
"chicken_weights": 0,
"ai_insights": 0,
}
for source_cycle_id in selected_ids:
cycle = cycle_map.get(source_cycle_id)
if cycle is None:
continue
series = export_client.collect_ops_series(
site_id=site_id,
cycle_id=source_cycle_id,
page_size=page_size,
)
# Upsert non-KPI series first so KPI FKs resolve.
series_totals["feed_sacks"] += _upsert_feed_sacks(
cycle, series.get("feed_sacks") or []
)
series_totals["chicken_countings"] += _upsert_chicken_countings(
cycle, series.get("chicken_countings") or []
)
series_totals["chicken_weights"] += _upsert_chicken_weights(
cycle, series.get("chicken_weights") or []
)
series_totals["manual_inputs"] += _upsert_manual_inputs(
cycle, series.get("manual_inputs") or []
)
series_totals["ai_insights"] += _upsert_ai_insights(
cycle, series.get("ai_insights") or []
)
series_totals["kpis"] += _upsert_kpis(cycle, series.get("kpis") or [])
summary.series_rows = series_totals
# IoT window = union of selected cycle spans (explicit from/to).
today = timezone.localdate()
window_from: date | None = None
window_to: date | None = None
for source_cycle_id in selected_ids:
cycle = cycle_map.get(source_cycle_id)
if cycle is None:
continue
start = cycle.start_date
end = cycle.end_date or today
window_from = start if window_from is None else min(window_from, start)
window_to = end if window_to is None else max(window_to, end)
flock_by_source: dict[int, Flock] = {}
if window_from is not None and window_to is not None:
iot_envelope, panels = export_client.collect_iot_export(
site_id=site_id,
date_from=window_from.isoformat(),
date_to=window_to.isoformat(),
page_size=page_size,
)
for site_node in iot_envelope.get("sites") or []:
for kandang_raw in site_node.get("kandangs") or []:
if not isinstance(kandang_raw, dict):
continue
source_kandang = _as_int(
kandang_raw.get("kandang_id") or kandang_raw.get("id")
)
kandang = (
kandang_by_source.get(source_kandang)
if source_kandang is not None
else None
)
if kandang is None:
kandang = _upsert_kandang(site, kandang_raw)
if source_kandang is not None:
kandang_by_source[source_kandang] = kandang
for flock_raw in kandang_raw.get("flocks") or []:
if not isinstance(flock_raw, dict):
continue
flock = _upsert_flock(kandang, flock_raw)
if flock is None:
continue
summary.flocks += 1
source_flock = _as_int(
flock_raw.get("flock_id") or flock_raw.get("id")
)
if source_flock is not None:
flock_by_source[source_flock] = flock
summary.iot_panels = _upsert_iot_panels(flock_by_source, panels)
summary.message = (
f"Synced {summary.selected_cycles} cycle(s), "
f"{sum(series_totals.values())} series row(s), "
f"{summary.iot_panels} IoT panel(s)."
)
_mark_active_site(active_site, summary)
return summary
except Exception as exc:
logger.exception("Mirror sync failed for active_site=%s", active_site.pk)
summary.status = STATUS_ERROR
summary.message = str(getattr(exc, "message", exc))[:2000]
# Avoid swallowing the outer atomic block with a nested failure write —
# mark status in a separate save after rollback via on_commit is awkward;
# write status outside transaction by raising after marking.
raise MirrorSyncFailed(summary) from exc
class MirrorSyncFailed(Exception):
def __init__(self, summary: MirrorSyncSummary):
self.summary = summary
super().__init__(summary.message)
def sync_active_site_safe(
active_site: ActiveSite,
*,
client: FarmExportClient | None = None,
closed_limit: int = CLOSED_LIMIT_PER_KANDANG,
page_size: int = DEFAULT_PAGE_SIZE,
) -> MirrorSyncSummary:
"""Sync one site; capture errors into ActiveSite.last_sync_* without raising."""
try:
return sync_active_site(
active_site,
client=client,
closed_limit=closed_limit,
page_size=page_size,
)
except MirrorSyncFailed as exc:
summary = exc.summary
ActiveSite.objects.filter(pk=active_site.pk).update(
last_synced_at=timezone.now(),
last_sync_status=summary.status,
last_sync_message=summary.message[:2000],
updated_at=timezone.now(),
)
return summary
except Exception as exc:
summary = MirrorSyncSummary(
active_site_id=active_site.pk,
status=STATUS_ERROR,
message=str(exc)[:2000],
)
ActiveSite.objects.filter(pk=active_site.pk).update(
last_synced_at=timezone.now(),
last_sync_status=summary.status,
last_sync_message=summary.message,
updated_at=timezone.now(),
)
return summary
def sync_all_active_sites(
*,
queryset=None,
closed_limit: int = CLOSED_LIMIT_PER_KANDANG,
page_size: int = DEFAULT_PAGE_SIZE,
) -> list[MirrorSyncSummary]:
qs = queryset
if qs is None:
qs = (
ActiveSite.objects.filter(is_active=True)
.exclude(source_site_id="")
.exclude(api_base_url="")
.exclude(api_key="")
)
results: list[MirrorSyncSummary] = []
for active_site in qs:
if not active_site.api_base_url or not active_site.api_key:
summary = MirrorSyncSummary(
active_site_id=active_site.pk,
status=STATUS_ERROR,
message="Missing api_base_url or api_key.",
)
ActiveSite.objects.filter(pk=active_site.pk).update(
last_synced_at=timezone.now(),
last_sync_status=summary.status,
last_sync_message=summary.message,
updated_at=timezone.now(),
)
results.append(summary)
continue
results.append(
sync_active_site_safe(
active_site, closed_limit=closed_limit, page_size=page_size
)
)
return results
@@ -0,0 +1,123 @@
import httpx
from django.test import SimpleTestCase, TestCase
from apps.accounts.models import User
from apps.sync.models import ActiveSite
from apps.sync.services.farm_export_client import FarmExportClient, FarmExportError
def _ops_page(*, page: int, page_size: int, count: int, results: list) -> dict:
return {
"sites": [],
"data": {
"kpis": {
"count": count,
"page": page,
"page_size": page_size,
"results": results,
},
"manual_inputs": {"count": 0, "page": page, "page_size": page_size, "results": []},
"feed_sacks": {"count": 0, "page": page, "page_size": page_size, "results": []},
"chicken_countings": {
"count": 0,
"page": page,
"page_size": page_size,
"results": [],
},
"chicken_weights": {
"count": 0,
"page": page,
"page_size": page_size,
"results": [],
},
"ai_insights": {"count": 0, "page": page, "page_size": page_size, "results": []},
},
}
class FarmExportClientTests(TestCase):
def setUp(self):
self.gm = User.objects.create_user(
user_name="gm-export", password="x", status=User.STATUS_GM
)
self.active = ActiveSite.objects.create(
code="sukawarna",
name="Sukawarna",
source_site_id="sukawarna-0002",
api_base_url="https://farm.example/api/v1",
api_key="site-secret",
managed_by=self.gm,
)
def test_401_raises_farm_export_error(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "nope"})
client = FarmExportClient(
self.active, transport=httpx.MockTransport(handler)
)
with self.assertRaises(FarmExportError) as ctx:
client.fetch_ops_export(site_id="sukawarna-0002")
self.assertEqual(ctx.exception.status_code, 401)
def test_collect_ops_series_paginates_until_done(self):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
page = int(request.url.params.get("page", "1"))
calls.append(page)
self.assertEqual(request.url.params.get("site_id"), "sukawarna-0002")
self.assertEqual(request.url.params.get("cycle_id"), "9")
self.assertEqual(request.headers.get("X-API-Key"), "site-secret")
if page == 1:
body = _ops_page(
page=1,
page_size=2,
count=3,
results=[{"date": "2026-09-01"}, {"date": "2026-09-02"}],
)
else:
body = _ops_page(
page=2,
page_size=2,
count=3,
results=[{"date": "2026-09-03"}],
)
return httpx.Response(200, json=body)
client = FarmExportClient(
self.active, transport=httpx.MockTransport(handler)
)
series = client.collect_ops_series(
site_id="sukawarna-0002", cycle_id=9, page_size=2
)
self.assertEqual(calls, [1, 2])
self.assertEqual(len(series["kpis"]), 3)
def test_missing_api_key_rejected(self):
self.active.api_key = ""
with self.assertRaises(FarmExportError):
FarmExportClient(self.active)
class SelectCycleIdsUnitTests(SimpleTestCase):
def test_selects_open_and_last_three_closed(self):
from apps.sync.services.mirror_pull import select_source_cycle_ids
rows = [
{"id": 1, "kandang": 10, "status": "active"},
{"id": 2, "kandang": 10, "status": "pending_close"},
{"id": 3, "kandang": 10, "status": "closed", "end_date": "2026-01-01"},
{"id": 4, "kandang": 10, "status": "closed", "end_date": "2026-02-01"},
{"id": 5, "kandang": 10, "status": "closed", "end_date": "2026-03-01"},
{"id": 6, "kandang": 10, "status": "closed", "end_date": "2026-04-01"},
{"id": 7, "kandang": 11, "status": "closed", "end_date": "2026-05-01"},
]
selected = select_source_cycle_ids(rows, closed_limit=3)
self.assertIn(1, selected)
self.assertIn(2, selected)
self.assertIn(6, selected)
self.assertIn(5, selected)
self.assertIn(4, selected)
self.assertNotIn(3, selected)
self.assertIn(7, selected)
+398
View File
@@ -0,0 +1,398 @@
import httpx
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import AIInsight, FeedSacks, IotPanel, KPI
from apps.sync.models import ActiveSite
from apps.sync.services.farm_export_client import FarmExportClient
from apps.sync.services.mirror_pull import sync_active_site_safe
from datetime import date
from unittest.mock import patch
def _empty_series(page=1, page_size=500):
block = {"count": 0, "page": page, "page_size": page_size, "results": []}
return {
"kpis": dict(block),
"manual_inputs": dict(block),
"feed_sacks": dict(block),
"chicken_countings": dict(block),
"chicken_weights": dict(block),
"ai_insights": dict(block),
}
class MirrorPullTests(TestCase):
def setUp(self):
self.gm = User.objects.create_user(
user_name="gm-mirror", password="x", status=User.STATUS_GM
)
self.active = ActiveSite.objects.create(
code="sukawarna",
name="Sukawarna",
source_site_id="sukawarna-0002",
api_base_url="https://farm.example/api/v1",
api_key="site-secret",
managed_by=self.gm,
is_active=True,
)
self.start = date(2026, 9, 1)
def _handler(self):
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
params = request.url.params
if path.endswith("/pusat/export/iot/"):
body = {
"sites": [
{
"site_id": "sukawarna-0002",
"site_name": "Sukawarna",
"kandangs": [
{
"kandang_id": 101,
"kandang_name": "K1",
"flocks": [
{"flock_id": 201, "flock_name": "Lantai 1"}
],
}
],
}
],
"data": {
"iot_panels": {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"flock_id": 201,
"date": "2026-09-02",
"timestamp": "2026-09-02T10:00:00+07:00",
"wind_speed": 1.2,
"humidity": 70.0,
"water_total": 10.0,
"average_temperature": 28.0,
"experience_temperature": 27.0,
}
],
}
},
}
return httpx.Response(200, json=body)
cycle_id = params.get("cycle_id")
if cycle_id:
series = _empty_series()
series["feed_sacks"] = {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"date": "2026-09-02",
"in_today": 5,
"out_today": 0,
"in_total": 5,
"out_total": 0,
"feed_use_today": 2,
"feed_use_total": 2,
"cycle": int(cycle_id),
}
],
}
series["chicken_countings"] = {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"date": "2026-09-02",
"total_count": 9900,
"mortality_count": 10,
"cycle": int(cycle_id),
}
],
}
series["chicken_weights"] = {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"date": "2026-09-02",
"age": 1,
"doc_weight": 42,
"average_weight": 50.0,
"chicken_count": 100,
"uniformity": 90.0,
"average_daily_gain": 8.0,
"cycle": int(cycle_id),
}
],
}
series["manual_inputs"] = {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"date": "2026-09-02",
"age_manual": 1,
"mortality_manual": 0,
"cycle": int(cycle_id),
}
],
}
series["ai_insights"] = {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"date": "2026-09-02",
"section": "health",
"session": "am",
"insight_text": "ok",
"alert": "none",
"cycle": int(cycle_id),
}
],
}
series["kpis"] = {
"count": 1,
"page": 1,
"page_size": 500,
"results": [
{
"date": "2026-09-02",
"age": 1,
"fcr": 1.2,
"eef": 300.0,
"chicken_life": 9900,
"cycle": int(cycle_id),
}
],
}
body = {
"site_id": "sukawarna-0002",
"cycle_id": int(cycle_id),
"sites": [],
"data": series,
}
return httpx.Response(200, json=body)
# Structure-only call
body = {
"site_id": "sukawarna-0002",
"sites": [
{
"site_id": "sukawarna-0002",
"site_name": "Sukawarna",
"kandangs": [
{
"kandang_id": 101,
"kandang_name": "K1",
"feed_in_button_urls": [],
"cycles": [
{
"id": 55,
"kandang": 101,
"status": "active",
"start_date": self.start.isoformat(),
"end_date": None,
"chick_in_weight": 42,
"doc_in_count": 10000,
"total_days": 1,
"feed_initial_balance": 120,
"feed_initial_balance_date": self.start.isoformat(),
"feed_initial_balance_iot": 115,
"feed_initial_balance_accuracy": 95.8,
"feed_initial_balance_sync_error": None,
},
{
"id": 50,
"kandang": 101,
"status": "closed",
"start_date": "2026-01-01",
"end_date": "2026-02-01",
"chick_in_weight": 40,
"doc_in_count": 9000,
"total_days": 32,
},
{
"id": 49,
"kandang": 101,
"status": "closed",
"start_date": "2025-10-01",
"end_date": "2025-11-01",
"chick_in_weight": 40,
"doc_in_count": 9000,
"total_days": 32,
},
{
"id": 48,
"kandang": 101,
"status": "closed",
"start_date": "2025-07-01",
"end_date": "2025-08-01",
"chick_in_weight": 40,
"doc_in_count": 9000,
"total_days": 32,
},
{
"id": 47,
"kandang": 101,
"status": "closed",
"start_date": "2025-04-01",
"end_date": "2025-05-01",
"chick_in_weight": 40,
"doc_in_count": 9000,
"total_days": 32,
},
],
}
],
}
],
"data": _empty_series(),
}
return httpx.Response(200, json=body)
return handler
def test_mirror_upsert_idempotent_and_selects_closed_limit(self):
transport = httpx.MockTransport(self._handler())
client = FarmExportClient(self.active, transport=transport)
summary = sync_active_site_safe(
self.active, client=client, closed_limit=3, page_size=500
)
self.assertEqual(summary.status, "ok", summary.message)
self.assertEqual(Cycle.objects.filter(active_site=self.active).count(), 5)
# active + last 3 closed = 4 selected (47 dropped)
self.assertEqual(summary.selected_cycles, 4)
self.assertFalse(
Cycle.objects.filter(active_site=self.active, source_cycle_id=47).exists()
and Cycle.objects.get(
active_site=self.active, source_cycle_id=47
).feed_sacks.exists()
)
# Oldest closed is still created from structure but not series-synced
self.assertTrue(
Cycle.objects.filter(active_site=self.active, source_cycle_id=47).exists()
)
active_cycle = Cycle.objects.get(active_site=self.active, source_cycle_id=55)
self.assertEqual(active_cycle.feed_initial_balance, 120)
self.assertEqual(active_cycle.feed_initial_balance_date, self.start)
self.assertEqual(active_cycle.feed_initial_balance_iot, 115)
self.assertEqual(active_cycle.feed_initial_balance_accuracy, 95.8)
self.assertIsNone(active_cycle.feed_initial_balance_sync_error)
self.assertEqual(FeedSacks.objects.filter(cycle=active_cycle).count(), 1)
self.assertEqual(KPI.objects.filter(cycle=active_cycle).count(), 1)
self.assertEqual(AIInsight.objects.filter(cycle=active_cycle).count(), 1)
self.assertEqual(Flock.objects.filter(source_flock_id=201).count(), 1)
self.assertEqual(IotPanel.objects.count(), 1)
# Second sync is idempotent
summary2 = sync_active_site_safe(
self.active, client=FarmExportClient(self.active, transport=transport)
)
self.assertEqual(summary2.status, "ok", summary2.message)
self.assertEqual(FeedSacks.objects.filter(cycle=active_cycle).count(), 1)
self.assertEqual(IotPanel.objects.count(), 1)
self.active.refresh_from_db()
self.assertEqual(self.active.last_sync_status, "ok")
def test_sync_api_endpoint(self):
transport = httpx.MockTransport(self._handler())
api = APIClient()
api.force_authenticate(self.gm)
with patch(
"apps.sync.services.mirror_pull.FarmExportClient",
side_effect=lambda active_site, **kwargs: FarmExportClient(
active_site, transport=transport
),
):
response = api.post(f"/api/v1/active-sites/{self.active.pk}/sync/")
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["summary"]["status"], "ok")
self.active.refresh_from_db()
self.assertEqual(self.active.last_sync_status, "ok")
class CloseIngestSourceKandangTests(TestCase):
def setUp(self):
self.gm = User.objects.create_user(
user_name="gm-ingest", password="x", status=User.STATUS_GM
)
self.machine = User.objects.create_user(
user_name="bot", password="x", status=User.STATUS_GM
)
self.active = ActiveSite.objects.create(
code="site-a",
name="Site A",
source_site_id="site-a-0001",
api_base_url="https://a.example/api/v1",
api_key="k",
managed_by=self.gm,
)
self.client = APIClient()
self.client.force_authenticate(self.machine)
def test_ingest_prefers_source_kandang_id(self):
response = self.client.post(
"/api/v1/cycle-close/ingest/",
{
"active_site_id": self.active.pk,
"source_cycle_id": 99,
"source_kandang_id": 777,
"kandang_name": "Kandang Baru",
"site_name": "Site A",
"start_date": "2026-09-01",
"proposed_end_date": "2026-09-15",
"doc_in_weight": 40,
"doc_in_count": 1000,
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
kandang = Kandang.objects.get(source_kandang_id=777)
self.assertEqual(kandang.kandang_name, "Kandang Baru")
cycle = Cycle.objects.get(active_site=self.active, source_cycle_id=99)
self.assertEqual(cycle.kandang_id, kandang.pk)
class AIInsightUniqueTests(TestCase):
def setUp(self):
owner = User.objects.create_user(user_name="owner", password="x")
site = Site.objects.create(site_id="s-1", site_name="S", user=owner)
kandang = Kandang.objects.create(site=site, kandang_name="K")
self.cycle = Cycle.objects.create(
kandang=kandang,
start_date=date.today(),
doc_in_weight=40,
doc_in_count=100,
)
def test_unique_constraint_allows_upsert_key(self):
AIInsight.objects.create(
cycle=self.cycle,
date=date.today(),
section="a",
session="am",
insight_text="one",
alert="",
)
AIInsight.objects.update_or_create(
cycle=self.cycle,
date=date.today(),
section="a",
session="am",
defaults={"insight_text": "two", "alert": "x"},
)
self.assertEqual(AIInsight.objects.filter(cycle=self.cycle).count(), 1)
self.assertEqual(
AIInsight.objects.get(cycle=self.cycle).insight_text, "two"
)
+31 -8
View File
@@ -19,7 +19,7 @@ class ActiveSiteViewSet(viewsets.ModelViewSet):
"""
List/read: scoped (director all; BUH managed GMs; GM own).
Create (claim): GM and GM Admin claim a site-registered ActiveSite by code.
Sync: GM and GM Admin only (stubs until mirror pull is implemented).
Sync: GM and GM Admin only (pulls farm pusat export into HQ mirror).
Site dashboards create/fill ActiveSite via register-from-site (API key auth).
Claiming attaches managed_by and ensures an HQ Site for the view list.
@@ -203,24 +203,47 @@ class ActiveSiteViewSet(viewsets.ModelViewSet):
{"detail": "Active site is inactive."},
status=status.HTTP_400_BAD_REQUEST,
)
if not active_site.api_base_url or not active_site.api_key:
return Response(
{"detail": "Active site is missing api_base_url or api_key."},
status=status.HTTP_400_BAD_REQUEST,
)
if not (active_site.source_site_id or "").strip():
return Response(
{"detail": "Active site is missing source_site_id."},
status=status.HTTP_400_BAD_REQUEST,
)
from apps.sync.services.mirror_pull import STATUS_OK, sync_active_site_safe
summary = sync_active_site_safe(active_site)
active_site.refresh_from_db()
http_status = (
status.HTTP_200_OK
if summary.status == STATUS_OK
else status.HTTP_502_BAD_GATEWAY
)
return Response(
{
"detail": "Active site sync not implemented yet.",
"detail": summary.message or "Sync finished.",
"summary": summary.to_dict(),
"active_site": ActiveSiteSerializer(active_site).data,
},
status=status.HTTP_501_NOT_IMPLEMENTED,
status=http_status,
)
@action(detail=False, methods=["post"], url_path="sync-all")
def sync_all(self, request):
if not can_sync(request.user):
return Response({"detail": "Sync not allowed."}, status=status.HTTP_403_FORBIDDEN)
from apps.sync.services.mirror_pull import STATUS_OK, sync_all_active_sites
sites = list(self.get_queryset().filter(is_active=True))
summaries = sync_all_active_sites(queryset=sites)
ok = sum(1 for s in summaries if s.status == STATUS_OK)
return Response(
{
"detail": "Active site sync-all not implemented yet.",
"active_site_count": len(sites),
"active_sites": ActiveSiteSerializer(sites, many=True).data,
},
status=status.HTTP_501_NOT_IMPLEMENTED,
"detail": f"Synced {ok}/{len(summaries)} active site(s).",
"active_site_count": len(summaries),
"summaries": [s.to_dict() for s in summaries],
}
)
+9
View File
@@ -209,3 +209,12 @@ CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED = env_bool(
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED = env_bool(
"CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", False
)
# HQ mirror pull from farm ActiveSite export APIs (ops + IoT).
ACTIVE_SITE_MIRROR_SYNC_ENABLED = env_bool("ACTIVE_SITE_MIRROR_SYNC_ENABLED", True)
ACTIVE_SITE_MIRROR_SYNC_PAGE_SIZE = int(
env("ACTIVE_SITE_MIRROR_SYNC_PAGE_SIZE", "500") or "500"
)
ACTIVE_SITE_MIRROR_CLOSED_LIMIT = int(
env("ACTIVE_SITE_MIRROR_CLOSED_LIMIT", "3") or "3"
)
+14
View File
@@ -121,17 +121,22 @@ CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id);
-- -----------------------------------------------------------------------------
-- 5. Kandang (Coop / Pen)
-- Optional source_kandang_id = farm kandang PK for mirror upserts.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kandang (
kandang_id BIGSERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL,
feed_in_button_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
source_kandang_id BIGINT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_kandang_site_source
ON kandang (site_id, source_kandang_id)
WHERE source_kandang_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 6. Flock
@@ -141,11 +146,15 @@ CREATE TABLE IF NOT EXISTS flock (
flock_id BIGSERIAL PRIMARY KEY,
flock_name VARCHAR(30) NOT NULL,
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
source_flock_id BIGINT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_flock_kandang_id ON flock (kandang_id);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_flock_kandang_source
ON flock (kandang_id, source_flock_id)
WHERE source_flock_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 7. Cycles
@@ -161,6 +170,9 @@ CREATE TABLE IF NOT EXISTS cycles (
doc_in_count INTEGER NOT NULL,
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
feed_initial_balance_date DATE,
feed_initial_balance_iot INTEGER,
feed_initial_balance_accuracy DOUBLE PRECISION,
feed_initial_balance_sync_error TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'active',
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
active_site_id BIGINT REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
@@ -227,6 +239,8 @@ CREATE TABLE IF NOT EXISTS ai_insight (
CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cycle_date_section_session
ON ai_insight (cycle_id, date, section, session);
-- -----------------------------------------------------------------------------
-- 10. Feed Sacks
+33 -31
View File
@@ -6,7 +6,6 @@ import type {
ChickenWeight,
Karung,
KPI,
LatestAvg,
ManualInput,
} from '../types/api.ts';
import type { View } from '../types/common.ts';
@@ -20,7 +19,7 @@ import {
formatNumber,
resolveCycleStatus,
} from '../utils/format.ts';
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts';
import { throughVisibleDate } from '../utils/visibility.ts';
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
@@ -44,7 +43,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
const [weights, setWeights] = useState<ChickenWeight[]>([]);
const [karung, setKarung] = useState<Karung | null>(null);
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily');
@@ -56,7 +54,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
setManualInputs([]);
setWeights([]);
setKarung(null);
setLatestAvg(null);
setInsights([]);
return;
}
@@ -79,19 +76,12 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
let avg: LatestAvg | null = null;
try {
avg = await api.weights.latestAvg({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
if (cancelled) return;
setKpis(kpiRows);
setCountings(countingRows);
setManualInputs(manualRows);
setWeights(weightRows);
setKarung(latestKarung);
setLatestAvg(avg);
setInsights(insightRows);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
@@ -105,40 +95,52 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
}, [selectedCycle]);
const cards: KpiCardModel[] = useMemo(() => {
// Countings/weights APIs are real-time; KPI cards still honor publish cutoff.
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
// Synced KPI rows only exist for days that had manual + sources on the site.
const visibleThrough = selectedCycle?.visible_through_date;
const visibleKpis = throughVisibleDate(kpis, visibleThrough);
const visibleCountings = throughVisibleDate(countings, visibleThrough);
const visibleWeights = throughVisibleDate(weights, visibleThrough);
const visibleLatestAvg =
latestAvg && isOnOrBeforeVisibleDate(latestAvg.date, visibleThrough) ? latestAvg : null;
const visibleKarung =
karung && isOnOrBeforeVisibleDate(karung.date, visibleThrough) ? karung : null;
const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null;
const kpisThroughAnchor = anchorDate
? visibleKpis.filter((row) => row.date <= anchorDate)
: [];
const weightsThroughAnchor = anchorDate
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
: [];
const weightOnAnchor = anchorDate
? weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0]
: null;
const latest = visibleKpis[0];
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(visibleKpis).map((row) => ({
const fcrTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
value: row.fcr,
label: trendLabel(row.date),
}));
const eefTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({
const eefTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
value: row.eef,
label: trendLabel(row.date),
}));
const mortalityTrend: KpiTrendPoint[] = chronological(visibleCountings).map((row) => ({
value: row.mortality_count,
const mortalityTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({
value: row.mortality,
label: trendLabel(row.date),
}));
const mortalityToday = latest?.mortality ?? visibleCountings[0]?.mortality_count ?? null;
const weightTrend: KpiTrendPoint[] = chronological(visibleWeights).map((row) => ({
const weightTrend: KpiTrendPoint[] = chronological(weightsThroughAnchor).map((row) => ({
value: row.average_weight,
label: trendLabel(row.date),
}));
const age = selectedCycle?.current_day ?? latest?.age ?? null;
const age = latest?.age ?? null;
const mortalityToday = latest?.mortality ?? null;
const feedToday = latest != null ? latest.feed : null;
const weightToday =
latest && latest.iot_weight > 0
? latest.iot_weight
: (weightOnAnchor?.average_weight ?? null);
return [
{
key: 'umur',
@@ -161,20 +163,20 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
{
key: 'karung',
title: 'Karung Dituang',
value: visibleKarung?.feed_use_today ?? visibleKarung?.out_today ?? null,
value: feedToday,
unit: 'karung',
icon: '🛢️',
status: visibleKarung ? 'ok' : 'na',
status: feedToday == null ? 'na' : 'ok',
targetView: 'feedSackCounting',
},
{
key: 'weight',
title: 'Berat AVG',
value: visibleLatestAvg?.average_weight ?? visibleWeights[0]?.average_weight ?? null,
value: weightToday,
unit: 'gram',
icon: '⚖️',
trend: weightTrend,
status: visibleLatestAvg || visibleWeights[0] ? 'ok' : 'na',
status: weightToday == null ? 'na' : 'ok',
targetView: 'weightMonitoring',
},
{
@@ -198,7 +200,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
targetView: 'eefDashboard',
},
];
}, [kpis, countings, weights, karung, latestAvg, selectedCycle]);
}, [kpis, weights, selectedCycle]);
const countingAccuracyWarning = useMemo(() => {
if (!selectedCycle) return null;
-18
View File
@@ -168,7 +168,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
const [rows, setRows] = useState<KPI[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
@@ -209,15 +208,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const eefRows = useMemo<EefRow[]>(
() =>
[...rows]
@@ -288,14 +278,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
subtitle="Dashboard European Efficiency Factor berdasarkan data performa produksi IoT."
icon={<i className="fa-solid fa-gauge-high" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
-18
View File
@@ -160,7 +160,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
@@ -204,15 +203,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const weightByAge = useMemo(() => {
const map = new Map<number, number>();
for (const weight of weightRows) {
@@ -293,14 +283,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
subtitle="Dashboard Feed Conversion Ratio berdasarkan data pakan, bobot IoT, ayam hidup, dan panen."
icon={<i className="fa-solid fa-chart-line" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
+1 -43
View File
@@ -17,9 +17,7 @@ const FeedSackCountingPage: 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 [showCyclePicker, setShowCyclePicker] = useState(false);
const reload = useCallback(async (cycleId: number) => {
@@ -69,22 +67,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
return Math.min(rawDay, Math.max(0, selectedCycle.total_days - 1));
}, [selectedCycle, displayDate]);
const handleSync = useCallback(async () => {
if (!selectedCycle) return;
setSyncing(true);
setError(null);
setNotice(null);
try {
await api.karungs.request({ cycle_id: selectedCycle.id, section: 'today' });
setNotice('Sinkron karung-web-admin berhasil.');
await reload(selectedCycle.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSyncing(false);
}
}, [selectedCycle, reload]);
const sortedCycles = useMemo(() => {
return [...cycles].sort(
(a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
@@ -131,25 +113,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
</p>
)}
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={handleSync}
disabled={syncing || cycleStatus !== 'active'}
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 hari ini
</>
)}
</button>
</div>
</div>
{/* Cycle Selector */}
@@ -229,11 +192,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<Banner kind="error">{error}</Banner>
</div>
)}
{notice && (
<div className="mb-4">
<Banner kind="success">{notice}</Banner>
</div>
)}
{loading ? (
<Spinner />
@@ -311,7 +269,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
)}
<div className="mt-6 text-xs text-gray-400">
Sumber data: karung-web-admin · {selectedSite ? selectedSite.site_name : ''}
Sumber data: site dashboard (mirror) · {selectedSite ? selectedSite.site_name : ''}
{selectedKandang ? ` · ${selectedKandang.kandang_name}` : ''}
</div>
</div>
+37 -2
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import { api, errorMessage } from '../services/apiClient.ts';
import type { UserStatus } from '../types/api.ts';
import { formatDateId, resolveCycleStatus } from '../utils/format.ts';
@@ -22,6 +23,10 @@ type HeaderProps = {
onToggleTimeline: () => void;
};
function canMirrorSync(status?: UserStatus | string | null): boolean {
return status === 'gm' || status === 'gm_admin';
}
const Header: React.FC<HeaderProps> = ({
onToggleSidebar,
isTimelineVisible,
@@ -38,12 +43,15 @@ const Header: React.FC<HeaderProps> = ({
setSelectedSiteId,
setSelectedKandangId,
setSelectedCycleId,
reload: reloadFarm,
} = useFarm();
const [open, setOpen] = useState(false);
const [gmFilter, setGmFilter] = useState<'all' | number>('all');
const [syncing, setSyncing] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const isBuh = user?.status === 'buh' || user?.status === 'bu_admin';
const showSync = canMirrorSync(user?.status);
const gmOptions = useMemo(() => {
const byId = new Map<number, string>();
@@ -81,6 +89,19 @@ const Header: React.FC<HeaderProps> = ({
return () => document.removeEventListener('mousedown', onClick);
}, []);
const handleMirrorSync = async () => {
setSyncing(true);
try {
await api.activeSites.syncAll();
await reloadFarm();
} catch (err) {
// Keep sync failures visible in the browser console; UI stays quiet.
console.error(errorMessage(err));
} finally {
setSyncing(false);
}
};
if (!user) return null;
return (
@@ -164,7 +185,21 @@ const Header: React.FC<HeaderProps> = ({
</select>
</div>
</div>
<div className="flex items-center ml-auto">
<div className="flex items-center ml-auto gap-2">
{showSync && (
<button
type="button"
onClick={() => void handleMirrorSync()}
disabled={syncing}
title="Sinkronkan data dari site dashboard"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60"
>
<i
className={`fa-solid ${syncing ? 'fa-spinner fa-spin' : 'fa-arrows-rotate'}`}
/>
<span className="hidden sm:inline">{syncing ? 'Sync...' : 'Sync'}</span>
</button>
)}
<button
onClick={onToggleTimeline}
title="Toggle Linimasa"
@@ -175,7 +210,7 @@ const Header: React.FC<HeaderProps> = ({
/>
</button>
<div className="relative ml-4" ref={dropdownRef}>
<div className="relative ml-2" ref={dropdownRef}>
<button
onClick={() => setOpen(!open)}
className="flex items-center space-x-2 focus:outline-none"
+3 -22
View File
@@ -59,9 +59,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const [selectedChartKey, setSelectedChartKey] = useState<'humidity' | 'wind' | 'water' | null>(
null
);
@@ -102,7 +100,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => {
cancelled = true;
};
}, [selectedKandang, refreshToken]);
}, [selectedKandang]);
useEffect(() => {
if (!selectedCycle || selectedFlockId == null) {
@@ -139,7 +137,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => {
cancelled = true;
};
}, [selectedCycle, selectedFlockId, refreshToken]);
}, [selectedCycle, selectedFlockId]);
useEffect(() => {
if (!selectedCycle || selectedFlockId == null || !selectedDate) {
@@ -171,7 +169,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => {
cancelled = true;
};
}, [selectedCycle, selectedFlockId, selectedDate, refreshToken]);
}, [selectedCycle, selectedFlockId, selectedDate]);
const summary = useMemo<DaySummary | null>(() => {
if (dayRows.length === 0) return null;
@@ -240,12 +238,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
const refresh = () => {
setRefreshing(true);
setRefreshToken((token) => token + 1);
window.setTimeout(() => setRefreshing(false), 500);
};
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
@@ -255,7 +247,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
icon={<i className="fa-solid fa-table-columns" />}
actions={
<div className="flex items-center gap-2">
<select
value={selectedFlockId ?? ''}
onChange={(event) =>
@@ -271,16 +262,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
</option>
))}
</select>
<button
type="button"
onClick={refresh}
disabled={refreshing}
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"
>
<i className={`fa-solid fa-rotate-right mr-1 ${refreshing ? 'fa-spin' : ''}`} />
Refresh
</button>
</div>
}
/>
+2 -1
View File
@@ -433,7 +433,8 @@ const GmViewList: React.FC = () => {
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Daftar tampilan</h2>
<p className="text-xs text-gray-500 mb-4">
Site yang terdaftar untuk akun GM / GM Admin Anda.
Site yang terdaftar untuk akun GM / GM Admin Anda. Sinkronisasi data memakai tombol Sync
di header.
</p>
{loading ? (
<Spinner />
+1 -24
View File
@@ -16,7 +16,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
const [latest, setLatest] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState('');
@@ -51,7 +50,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
let cancelled = false;
(async () => {
setLoading(true);
setRefreshing(false);
try {
await reload(selectedCycle.id);
} finally {
@@ -78,18 +76,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
}
}, [rows, chartRows, selectedDate]);
const handleRefresh = async () => {
if (!selectedCycle) return;
setRefreshing(true);
try {
await reload(selectedCycle.id);
} catch {
// error state is set inside reload
} finally {
setRefreshing(false);
}
};
const docWeight = selectedCycle?.chick_in_weight ?? 35;
const selectedRow = useMemo(
@@ -149,15 +135,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
)}
</div>
</div>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
title="Refresh data manual"
>
<i className={`fa-solid fa-refresh ${refreshing || loading ? 'fa-spin' : ''}`}></i>
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
@@ -297,7 +274,7 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
<div>
<h3 className="font-semibold text-blue-900 mb-2">Informasi Pembaruan Data</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li>• Klik tombol "Refresh" untuk memperbarui data secara manual</li>
<li>• Data berat diperbarui lewat Sync di header (dari site dashboard)</li>
<li>• Grafik menampilkan data dari awal siklus hingga hari ini</li>
<li>• Target berat menggunakan standar performa Ross 308</li>
</ul>
@@ -229,7 +229,7 @@ describe('FeedSackCountingPage', () => {
screen.getByText('Monitoring karung masuk dan karung dituang per hari')
).toBeInTheDocument();
expect(screen.getByText(/10 Agu 2026 - 13 Sep 2026/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sync hari ini/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Sync/i })).not.toBeInTheDocument();
// Ringkasan stats
expect(screen.getAllByText('Karung Masuk').length).toBeGreaterThan(0);
@@ -240,6 +240,13 @@ describe('FeedSackCountingPage', () => {
// Saldo card
expect(screen.getByRole('heading', { name: 'Saldo Karung IOT' })).toBeInTheDocument();
// Saldo awal (read-only from site mirror)
expect(screen.getByRole('heading', { name: 'Saldo Awal' })).toBeInTheDocument();
expect(
screen.getByText('Belum ada saldo awal tersimpan untuk siklus ini.')
).toBeInTheDocument();
expect(screen.getByText(/Sumber data: site dashboard \(mirror\)/i)).toBeInTheDocument();
// Trend chart
expect(screen.getByRole('heading', { name: 'Tren Harian Karung IOT' })).toBeInTheDocument();
+5 -1
View File
@@ -101,6 +101,7 @@ describe('SettingsPage city site registry', () => {
id: 1,
code: 'sukawarna',
name: 'Sukawarna',
source_site_id: 'sukawarna-0001',
api_base_url: 'https://sukawarna.example/api/v1',
has_api_key: true,
managed_by: 5,
@@ -117,6 +118,7 @@ describe('SettingsPage city site registry', () => {
id: 2,
code: 'cikupa',
name: 'Cikupa',
source_site_id: 'cikupa-0001',
api_base_url: 'https://cikupa.example/api/v1',
has_api_key: false,
managed_by: 5,
@@ -165,7 +167,9 @@ describe('SettingsPage city site registry', () => {
expect(createMock).toHaveBeenCalledWith({
code: 'cikupa',
});
}); it('lets GM request a BUH', async () => {
});
it('lets GM request a BUH', async () => {
requestMock.mockResolvedValue({
...authState.user,
managed_by_pending: 2,
@@ -51,9 +51,7 @@ 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');
@@ -91,22 +89,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
void loadData(selectedCycle.id);
}, [selectedCycle, loadData]);
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 [];
@@ -281,26 +263,6 @@ 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>
}
/>
{countingAccuracyWarning && (
@@ -351,11 +313,6 @@ 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">
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DataManagementPage from '../DataManagementPage.tsx';
import { api } from '../../../services/apiClient.ts';
@@ -12,7 +12,6 @@ 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([
@@ -78,10 +77,6 @@ describe('DataManagementPage', () => {
beforeEach(() => {
vi.mocked(api.kpis.list).mockResolvedValue([]);
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 () => {
@@ -114,17 +109,6 @@ describe('DataManagementPage', () => {
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 });
});
});
it('shows under-count IoT accuracy warning for the latest IoT day', async () => {
vi.mocked(api.kpis.list).mockResolvedValue([
{
@@ -93,7 +93,7 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
<i className="fa-solid fa-table text-5xl text-gray-300 mb-3"></i>
<p className="font-medium text-gray-600">Tidak ada data</p>
<p className="text-sm text-gray-500 mt-1">
Belum ada data karung IOT untuk siklus ini. Sinkronkan lewat tombol Sync hari ini.
Belum ada data karung IOT untuk siklus ini. Gunakan Sync di header untuk menarik data dari site.
</p>
</div>
</div>
+1 -1
View File
@@ -109,7 +109,7 @@ const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
<i className="fa-solid fa-triangle-exclamation text-yellow-600 mt-0.5"></i>
<p className="text-xs text-yellow-800">
<strong>Peringatan:</strong> Saldo negatif menunjukkan karung dituang lebih banyak dari
karung masuk. Periksa kembali data sinkronisasi karung-web-admin.
karung masuk. Pastikan site sudah disinkronkan dari header.
</p>
</div>
)}
+21 -102
View File
@@ -1,7 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import type { Cycle, InitialBalanceResult } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
import React from 'react';
import type { Cycle } from '../../types/api.ts';
import InitialBalanceComparison from './InitialBalanceComparison.tsx';
interface InitialBalanceCardProps {
@@ -9,53 +7,9 @@ interface InitialBalanceCardProps {
}
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle }) => {
const savedBalanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
const [balanceDate, setBalanceDate] = useState(savedBalanceDate);
const [manualEntry, setManualEntry] = useState<string>(
cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : ''
);
const [comparing, setComparing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [comparison, setComparison] = useState<InitialBalanceResult | null>(null);
useEffect(() => {
setBalanceDate(cycle.feed_initial_balance_date ?? cycle.start_date);
setManualEntry(cycle.feed_initial_balance > 0 ? String(cycle.feed_initial_balance) : '');
setComparison(null);
setError(null);
}, [cycle.id, cycle.feed_initial_balance, cycle.feed_initial_balance_date, cycle.start_date]);
const manualValue = manualEntry === '' ? null : Number(manualEntry);
const loadComparison = useCallback(async () => {
if (balanceDate === '' || manualValue == null || Number.isNaN(manualValue) || manualValue < 0) {
setComparison(null);
return;
}
setComparing(true);
setError(null);
try {
const compared = await api.cycles.initialBalanceCompare(cycle.id, {
date: balanceDate,
manual: manualValue,
});
setComparison(compared);
} catch (err) {
setComparison(null);
setError(errorMessage(err));
} finally {
setComparing(false);
}
}, [balanceDate, cycle.id, manualValue]);
useEffect(() => {
if (manualValue == null || manualValue < 0) {
setComparison(null);
return;
}
void loadComparison();
}, [loadComparison, manualValue]);
const hasBalance =
cycle.feed_initial_balance > 0 || Boolean(cycle.feed_initial_balance_date);
const balanceDate = cycle.feed_initial_balance_date ?? cycle.start_date;
return (
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
@@ -66,64 +20,29 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle }) => {
<div>
<h2 className="text-xl font-bold text-gray-800">Saldo Awal</h2>
<p className="text-sm text-gray-600">
Masukkan saldo awal karung pakan pada tanggal tertentu, lalu bandingkan dengan deteksi
IoT.
Saldo awal karung pakan dari site dashboard (mirror sync).
</p>
</div>
</div>
<div className="bg-white rounded-lg p-6 border border-blue-200">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="initial-balance-date"
className="text-sm font-semibold text-gray-700 block mb-1"
>
Tanggal
</label>
<input
id="initial-balance-date"
type="date"
value={balanceDate}
onChange={(event) => setBalanceDate(event.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
<div>
<label
htmlFor="initial-balance-manual"
className="text-sm font-semibold text-gray-700 block mb-1"
>
Feed In Manual (karung)
</label>
<input
id="initial-balance-manual"
type="number"
min={0}
value={manualEntry}
onChange={(event) => setManualEntry(event.target.value)}
placeholder="Contoh: 120"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
</div>
</div>
{error && (
<p className="mt-3 text-sm font-semibold text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">
{error}
{!hasBalance ? (
<p className="text-sm text-gray-600">Belum ada saldo awal tersimpan untuk siklus ini.</p>
) : (
<>
<p className="text-sm text-gray-600 mb-1">
Tanggal:{' '}
<strong className="text-gray-800">{balanceDate}</strong>
</p>
<InitialBalanceComparison
date={balanceDate}
manual={cycle.feed_initial_balance}
iot={cycle.feed_initial_balance_iot ?? null}
accuracy={cycle.feed_initial_balance_accuracy ?? null}
syncError={cycle.feed_initial_balance_sync_error ?? null}
/>
</>
)}
{cycle.feed_initial_balance > 0 && comparison == null && (
<p className="mt-4 text-sm text-gray-600">
Saldo awal tersimpan:{' '}
<strong className="text-blue-700">
{formatNumber(cycle.feed_initial_balance)} karung
</strong>
</p>
)}
{comparison && <InitialBalanceComparison result={comparison} loading={comparing} />}
</div>
</div>
);
@@ -1,17 +1,23 @@
import React from 'react';
import type { InitialBalanceResult } from '../../types/api.ts';
import { formatNumber } from '../../utils/format.ts';
interface InitialBalanceComparisonProps {
result: InitialBalanceResult;
date: string;
manual: number;
iot: number | null;
accuracy: number | null;
syncError?: string | null;
loading?: boolean;
}
const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
result,
date,
manual,
iot,
accuracy,
syncError = null,
loading = false,
}) => {
const accuracy = result.accuracy;
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
const accuracyColor =
accuracy == null
@@ -25,18 +31,18 @@ const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
return (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50/50 p-4">
<p className="text-sm font-semibold text-gray-800 mb-3">
Perbandingan {result.date}
{loading ? ' · memuat IoT...' : ''}
Perbandingan {date}
{loading ? ' · memuat...' : ''}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-center">
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-blue-700">Manual</p>
<p className="text-2xl font-bold text-gray-800">{formatNumber(result.manual)} karung</p>
<p className="text-2xl font-bold text-gray-800">{formatNumber(manual)} karung</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
<p className="text-xs font-semibold text-purple-700">IOT</p>
<p className="text-2xl font-bold text-gray-800">
{result.iot == null ? 'N/A' : `${formatNumber(result.iot)} karung`}
{iot == null ? 'N/A' : `${formatNumber(iot)} karung`}
</p>
</div>
<div className="rounded-lg bg-white border border-gray-200 p-3">
@@ -44,9 +50,9 @@ const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
</div>
</div>
{result.sync_error && (
{syncError && (
<p className="mt-3 text-xs text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
Gagal menarik data IoT dari karung-web-admin: {result.sync_error}
Sinkron IoT di site: {syncError}
</p>
)}
</div>
+4 -26
View File
@@ -51,21 +51,10 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-change_this_to_a_strong_password}
DB_HOST: database
DB_PORT: "5432"
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17}
DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0}
IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-false}
IOT_API_BASE_URL: ${IOT_API_BASE_URL:-}
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:-false}
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-false}
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED:-false}
ACTIVE_SITE_MIRROR_SYNC_ENABLED: ${ACTIVE_SITE_MIRROR_SYNC_ENABLED:-true}
ACTIVE_SITE_MIRROR_CLOSED_LIMIT: ${ACTIVE_SITE_MIRROR_CLOSED_LIMIT:-3}
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-}
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n}
@@ -111,21 +100,10 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-change_this_to_a_strong_password}
DB_HOST: database
DB_PORT: "5432"
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17}
DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0}
IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-false}
IOT_API_BASE_URL: ${IOT_API_BASE_URL:-}
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:-false}
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-false}
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED:-false}
ACTIVE_SITE_MIRROR_SYNC_ENABLED: ${ACTIVE_SITE_MIRROR_SYNC_ENABLED:-true}
ACTIVE_SITE_MIRROR_CLOSED_LIMIT: ${ACTIVE_SITE_MIRROR_CLOSED_LIMIT:-3}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
+3 -17
View File
@@ -27,24 +27,10 @@ BOOTSTRAP_STAFF_PASSWORD=
# Optional: fixed API key for active-site inbound / scripts (hashed at rest)
BOOTSTRAP_API_KEY=
# City-edge syncs OFF at HQ — data should arrive via active-site mirror sync.
# Enable only if this box also pulls directly from edge services.
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=false
# HQ mirror pull from farm pusat export APIs (ops + IoT)
ACTIVE_SITE_MIRROR_SYNC_ENABLED=true
ACTIVE_SITE_MIRROR_CLOSED_LIMIT=3
# Dashboard publish cutoff (WIB): day D visible from 17:00 on day D
DASHBOARD_PUBLISH_HOUR=17
DASHBOARD_PUBLISH_MINUTE=0
# Optional edge endpoints (unused while sync flags are false)
KARUNG_WEB_ADMIN_BASE_URL=http://host.docker.internal:5000
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
IOT_API_BASE_URL=
IOT_SYNC_LOOKBACK_MINUTES=20
IOT_SYNC_MAX_PAGES=10
IOT_FLOCK_ID_MAP={}
CHICKEN_COUNTING_EDGE_BASE_URL=
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30
+3
View File
@@ -3,8 +3,11 @@ PATH=/usr/local/bin:/usr/bin:/bin
# NOTE: docker/entrypoint-cron.sh regenerates this file from DASHBOARD_PUBLISH_HOUR/MINUTE
# at container start. Edge syncs no-op when *_SYNC_ENABLED=false (HQ default).
# Active-site mirror pull is ON by default (ACTIVE_SITE_MIRROR_SYNC_ENABLED).
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1
*/30 * * * * root cd /app && /usr/local/bin/python manage.py sync_active_sites_from_farm >> /var/log/cron.log 2>&1
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
+3 -7
View File
@@ -3,6 +3,7 @@ import type {
ChickenCounting,
ChickenWeight,
ActiveSite,
ActiveSiteSyncResponse,
ActiveSiteWrite,
CountingWrite,
Cycle,
@@ -10,7 +11,6 @@ import type {
CycleWrite,
Flock,
FlockWrite,
InitialBalanceResult,
Karung,
KarungRequestResult,
Kandang,
@@ -171,6 +171,8 @@ export const api = {
update: (id: number, body: Partial<ActiveSiteWrite>) =>
patch<ActiveSite>(`/active-sites/${id}/`, body),
remove: (id: number) => del(`/active-sites/${id}/`),
sync: (id: number) => post<ActiveSiteSyncResponse>(`/active-sites/${id}/sync/`, {}),
syncAll: () => post<ActiveSiteSyncResponse>('/active-sites/sync-all/', {}),
},
users: {
list: async () => unwrapList(await get<Paginated<User> | User[]>('/users/')),
@@ -214,12 +216,6 @@ export const api = {
create: (body: CycleWrite) => post<Cycle>('/cycles/', body),
update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body),
remove: (id: number) => del(`/cycles/${id}/`),
initialBalance: (id: number, body: { date: string; feed_in_manual: number }) =>
post<InitialBalanceResult>(`/cycles/${id}/initial-balance/`, body),
initialBalanceCompare: (
id: number,
query: { date: string; manual?: number }
) => get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
approveClose: (id: number) =>
post<Cycle | { cycle: Cycle; site_error?: string }>(`/cycles/${id}/approve-close/`, {}),
rejectClose: (id: number) =>
+23 -11
View File
@@ -64,6 +64,26 @@ export type ActiveSiteWrite = {
is_active?: boolean;
};
export type ActiveSiteSyncSummary = {
active_site_id: number;
status: string;
message: string;
kandangs: number;
cycles: number;
flocks: number;
selected_cycles: number;
series_rows: Record<string, number>;
iot_panels: number;
};
export type ActiveSiteSyncResponse = {
detail: string;
summary?: ActiveSiteSyncSummary;
summaries?: ActiveSiteSyncSummary[];
active_site?: ActiveSite;
active_site_count?: number;
};
export type Site = {
id: string;
site_name: string;
@@ -106,6 +126,9 @@ export type Cycle = {
doc_in_count: number;
feed_initial_balance: number;
feed_initial_balance_date?: string | null;
feed_initial_balance_iot?: number | null;
feed_initial_balance_accuracy?: number | null;
feed_initial_balance_sync_error?: string | null;
status: CycleStatus;
active_site?: number | null;
source_cycle_id?: number | null;
@@ -296,17 +319,6 @@ export type CycleWrite = {
status?: CycleStatus;
};
export type InitialBalanceResult = {
cycle: number;
date: string;
feed_initial_balance: number;
feed_initial_balance_date: string | null;
manual: number;
manual_total: number;
iot: number | null;
accuracy: number | null;
sync_error: string | null;
};
export type CountingWrite = {
cycle: number;
date: string;