update sync data from site dashboard and testing closing cycles
This commit is contained in:
1 parent
36639d8c38
commit
f49e72592c
42 files changed
+1941
-663
No files matched your search
@@ -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
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
)
|
||||
+19
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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],
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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,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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user