update sync data from site dashboard and testing closing cycles

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

No files matched your search

@@ -0,0 +1,45 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0019_site_id_slug"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="source_kandang_id",
field=models.BigIntegerField(
blank=True,
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
null=True,
),
),
migrations.AddField(
model_name="flock",
name="source_flock_id",
field=models.BigIntegerField(
blank=True,
help_text="flock_id on the farm-location dashboard-cpsp deployment",
null=True,
),
),
migrations.AddConstraint(
model_name="kandang",
constraint=models.UniqueConstraint(
condition=models.Q(("source_kandang_id__isnull", False)),
fields=("site", "source_kandang_id"),
name="uniq_kandang_site_source",
),
),
migrations.AddConstraint(
model_name="flock",
constraint=models.UniqueConstraint(
condition=models.Q(("source_flock_id__isnull", False)),
fields=("kandang", "source_flock_id"),
name="uniq_flock_kandang_source",
),
),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0020_kandang_flock_source_ids"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_iot",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_accuracy",
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_sync_error",
field=models.TextField(blank=True, null=True),
),
]
+27
View File
@@ -68,12 +68,24 @@ class Kandang(models.Model):
kandang_name = models.CharField(max_length=30)
feed_in_button_urls = models.JSONField(default=list, blank=True)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
source_kandang_id = models.BigIntegerField(
null=True,
blank=True,
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
constraints = [
models.UniqueConstraint(
fields=["site", "source_kandang_id"],
condition=models.Q(source_kandang_id__isnull=False),
name="uniq_kandang_site_source",
),
]
def __str__(self) -> str:
return self.kandang_name
@@ -87,12 +99,24 @@ class Flock(models.Model):
on_delete=models.CASCADE,
related_name="flocks",
)
source_flock_id = models.BigIntegerField(
null=True,
blank=True,
help_text="flock_id on the farm-location dashboard-cpsp deployment",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "flock"
ordering = ["flock_name"]
constraints = [
models.UniqueConstraint(
fields=["kandang", "source_flock_id"],
condition=models.Q(source_flock_id__isnull=False),
name="uniq_flock_kandang_source",
),
]
def __str__(self) -> str:
return self.flock_name
@@ -116,6 +140,9 @@ class Cycle(models.Model):
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
feed_initial_balance_iot = models.IntegerField(null=True, blank=True)
feed_initial_balance_accuracy = models.FloatField(null=True, blank=True)
feed_initial_balance_sync_error = models.TextField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
+17 -3
View File
@@ -38,10 +38,11 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name",
"feed_in_button_urls",
"site",
"source_kandang_id",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
read_only_fields = ["id", "source_kandang_id", "created_at", "updated_at"]
def validate_feed_in_button_urls(self, value):
cleaned = []
@@ -77,8 +78,15 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
fields = [
"id",
"flock_name",
"kandang",
"source_flock_id",
"created_at",
"updated_at",
]
read_only_fields = ["id", "source_flock_id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
@@ -105,6 +113,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"doc_in_count",
"feed_initial_balance",
"feed_initial_balance_date",
"feed_initial_balance_iot",
"feed_initial_balance_accuracy",
"feed_initial_balance_sync_error",
"status",
"active_site",
"source_cycle_id",
@@ -121,6 +132,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"feed_initial_balance_iot",
"feed_initial_balance_accuracy",
"feed_initial_balance_sync_error",
"status",
"active_site",
"source_cycle_id",
+18 -119
View File
@@ -6,7 +6,6 @@ from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import FeedSacks, ManualInput
from apps.sync.models import ActiveSite
@@ -264,7 +263,7 @@ class FarmAPITests(TestCase):
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 2)
def test_set_initial_balance_does_not_create_feed_sack_row(self):
def test_initial_balance_write_endpoints_removed(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
@@ -275,123 +274,21 @@ class FarmAPITests(TestCase):
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
post = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 120)
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=1),
in_today=4,
in_total=4,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
compare = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
self.assertEqual(post.status_code, 404)
self.assertEqual(compare.status_code, 404)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 4)
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self):
def test_cycle_api_exposes_mirrored_initial_balance_iot_fields(self):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=2),
in_today=6,
in_total=6,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
first = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(first.status_code, 200, first.data)
second = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 80)
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 6)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 115)
self.assertEqual(response.data["accuracy"], 95.8)
mock_pull.assert_called_once_with(cycle, start)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
start = date(2026, 8, 27)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
@@ -400,15 +297,17 @@ class FarmAPITests(TestCase):
doc_in_weight=40,
doc_in_count=9000,
feed_initial_balance=120,
feed_initial_balance_date=start,
feed_initial_balance_iot=115,
feed_initial_balance_accuracy=95.8,
feed_initial_balance_sync_error=None,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 112)
self.assertEqual(response.data["accuracy"], 93.3)
mock_pull.assert_called_once_with(cycle, start)
self.assertEqual(response.data["feed_initial_balance"], 120)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
self.assertEqual(response.data["feed_initial_balance_iot"], 115)
self.assertEqual(response.data["feed_initial_balance_accuracy"], 95.8)
self.assertIsNone(response.data["feed_initial_balance_sync_error"])
+29 -135
View File
@@ -15,14 +15,6 @@ from apps.accounts.scoping import (
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.site_close import SiteClosePushError, push_close_decision
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.sync.models import ActiveSite
@@ -61,11 +53,35 @@ 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"])
kandang, _ = Kandang.objects.get_or_create(
site=site,
kandang_name=kandang_name,
defaults={},
)
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,
defaults={},
)
cycle = Cycle.objects.filter(
active_site=active_site, source_cycle_id=source_cycle_id
@@ -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,
}
)