update add feed initial balance on cycle and fix its feature

This commit is contained in:
Alberto-Audrix committed 2026-09-01 14:02:41 +07:00
1 parent 91444faa31
commit 7a9d600aa6
16 files changed
+439 -88

No files matched your search

@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0011_flock_belongs_to_kandang"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_date",
field=models.DateField(blank=True, null=True),
),
]
+1
View File
@@ -70,6 +70,7 @@ class Cycle(models.Model):
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
+2
View File
@@ -46,6 +46,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"created_at",
"updated_at",
@@ -54,6 +55,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"id",
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"created_at",
"updated_at",
]
+50
View File
@@ -1,4 +1,5 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
@@ -106,6 +107,7 @@ class FarmAPITests(TestCase):
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
row = FeedSacks.objects.get(cycle=cycle)
self.assertEqual(row.date, start)
@@ -143,3 +145,51 @@ class FarmAPITests(TestCase):
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 124)
@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,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
feed_initial_balance=120,
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},
)
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)
+64 -28
View File
@@ -1,6 +1,3 @@
from datetime import date, timedelta
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
@@ -14,7 +11,10 @@ from apps.operations.services.feed_sacks import (
ensure_opening_feed_sack_snapshot,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.operations.services.manual_input import recompute_manual_totals
@@ -85,6 +85,53 @@ class CycleViewSet(viewsets.ModelViewSet):
qs = qs.filter(status=params["status"])
return qs
@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)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
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": manual_row.feed_in_manual_total if manual_row else 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).
@@ -92,7 +139,7 @@ class CycleViewSet(viewsets.ModelViewSet):
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation), records the manual feed-in
for the balance date, then pulls the IoT-detected "in" for that date from
the karung-web-admin feed and returns a manual vs IoT accuracy figure.
karung-web-admin ``/api/combined`` and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
@@ -120,25 +167,13 @@ class CycleViewSet(viewsets.ModelViewSet):
)
cycle.feed_initial_balance = feed_in_manual
cycle.save(update_fields=["feed_initial_balance", "updated_at"])
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 (karung-web-admin syncs today/yesterday).
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
sync_error = None
if feed_sack is None:
try:
today = date.today()
section = None
if balance_date == today:
section = "today"
elif balance_date == today - timedelta(days=1):
section = "yesterday_history"
if section:
request_karung(cycle, section=section)
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
except (KarungWebError, ValidationError) as exc:
sync_error = str(exc)
iot_in = feed_sack.in_today if feed_sack is not None else None
# Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
# Record the manual feed-in for the balance date.
ManualInput.objects.update_or_create(
@@ -152,11 +187,7 @@ class CycleViewSet(viewsets.ModelViewSet):
recompute_feed_sack_in_totals(cycle)
ensure_opening_feed_sack_snapshot(cycle, balance_date)
accuracy = None
if feed_in_manual > 0 and iot_in is not None and iot_in > 0:
accuracy = round(min(feed_in_manual, iot_in) / max(feed_in_manual, iot_in) * 1000) / 10
elif feed_in_manual == 0 and iot_in == 0:
accuracy = 100.0
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
@@ -164,6 +195,11 @@ class CycleViewSet(viewsets.ModelViewSet):
"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": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"iot": iot_in,