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,
+70 -4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import date
from typing import Any
from urllib.parse import urljoin
@@ -18,17 +19,34 @@ class KarungWebError(Exception):
self.status_code = status_code
def masuk_source_for_kandang(kandang_name: str) -> str:
"""Map a kandang label like ``Kandang 1`` to the karung-web-admin source ``K1 - In``."""
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive IoT masuk source from kandang name: {kandang_name!r}"
)
return f"K{match.group(1)} - In"
def _normalize_source(value: str | None) -> str:
return (value or "").strip().casefold()
class KarungWebClient:
"""Outbound client for karung-web-admin — base URL from settings only."""
PATH = "/api/v1/cycles/active/sections"
SECTIONS_PATH = "/api/v1/cycles/active/sections"
COMBINED_PATH = "/api/combined"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.KARUNG_WEB_ADMIN_BASE_URL).rstrip("/")
self.timeout = timeout or settings.KARUNG_WEB_ADMIN_TIMEOUT_SECONDS
def fetch_active_sections(self) -> dict[str, Any]:
url = urljoin(self.base_url + "/", self.PATH.lstrip("/"))
def _get_json(self, path: str) -> dict[str, Any]:
if not self.base_url:
raise KarungWebError("KARUNG_WEB_ADMIN_BASE_URL is not configured")
url = urljoin(self.base_url + "/", path.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
@@ -40,11 +58,59 @@ class KarungWebClient:
status_code=response.status_code,
)
payload = response.json()
if not payload.get("success", True):
if not payload.get("success", True) and payload.get("error"):
error = payload.get("error") or {}
raise KarungWebError(error.get("message") or "karung-web-admin returned failure")
return payload.get("data") or payload
def fetch_active_sections(self) -> dict[str, Any]:
return self._get_json(self.SECTIONS_PATH)
def fetch_combined(self) -> dict[str, Any]:
return self._get_json(self.COMBINED_PATH)
def fetch_iot_masuk_for_date(target_date: date, *, kandang_name: str) -> int | None:
"""Return IoT karung-masuk for ``target_date`` and kandang from karung-web-admin /api/combined."""
expected_source = _normalize_source(masuk_source_for_kandang(kandang_name))
target = target_date.isoformat()
client = KarungWebClient()
data = client.fetch_combined()
masuk = data.get("masuk") or {}
for row in masuk.get("history") or []:
if row.get("date") == target and _normalize_source(row.get("source")) == expected_source:
return int(row.get("counter_value") or 0)
if target_date == date.today():
for row in masuk.get("today") or []:
if _normalize_source(row.get("source")) == expected_source:
return int(row.get("karung") or 0)
return None
def initial_balance_accuracy(manual: int, iot_in: int | None) -> float | None:
if manual > 0 and iot_in is not None and iot_in > 0:
return round(min(manual, iot_in) / max(manual, iot_in) * 1000) / 10
if manual == 0 and iot_in == 0:
return 100.0
return None
def pull_initial_balance_iot(cycle: Cycle, balance_date: date) -> tuple[int | None, str | None]:
sync_error = None
iot_in = None
try:
iot_in = fetch_iot_masuk_for_date(
balance_date,
kandang_name=cycle.kandang.kandang_name,
)
except KarungWebError as exc:
sync_error = str(exc)
return iot_in, sync_error
def request_karung(
cycle: Cycle,
@@ -0,0 +1,99 @@
from datetime import date
from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from apps.operations.services.karung_web import (
KarungWebError,
fetch_iot_masuk_for_date,
initial_balance_accuracy,
masuk_source_for_kandang,
)
COMBINED_SAMPLE = {
"masuk": {
"history": [
{"counter_value": 160, "date": "2026-09-02", "id": 17, "source": "K1 - In"},
{"counter_value": 80, "date": "2026-09-02", "id": 19, "source": "K2 - In"},
{"counter_value": 112, "date": "2026-08-27", "id": 9, "source": "K1 - In"},
{"counter_value": 32, "date": "2026-08-27", "id": 11, "source": "K2 - In"},
],
"today": [
{"karung": 160, "source": "K1 - In"},
{"karung": 80, "source": "K2 - In"},
],
}
}
class MasukSourceForKandangTests(TestCase):
def test_maps_kandang_number_to_iot_source(self):
self.assertEqual(masuk_source_for_kandang("Kandang 1"), "K1 - In")
self.assertEqual(masuk_source_for_kandang("Kandang 2"), "K2 - In")
def test_rejects_kandang_name_without_number(self):
with self.assertRaises(ValidationError):
masuk_source_for_kandang("Utara")
class InitialBalanceAccuracyTests(TestCase):
def test_returns_ratio_when_both_values_positive(self):
self.assertEqual(initial_balance_accuracy(120, 115), 95.8)
def test_returns_100_when_both_zero(self):
self.assertEqual(initial_balance_accuracy(0, 0), 100.0)
def test_returns_none_when_iot_missing(self):
self.assertIsNone(initial_balance_accuracy(120, None))
@override_settings(KARUNG_WEB_ADMIN_BASE_URL="http://karung.internal")
class KarungWebCombinedTests(TestCase):
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_uses_kandang_specific_history_row(self, mock_fetch):
mock_fetch.return_value = COMBINED_SAMPLE
result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 2")
self.assertEqual(result, 32)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_matches_source_case_insensitively(self, mock_fetch):
mock_fetch.return_value = {
"masuk": {
"history": [
{"counter_value": 99, "date": "2026-08-27", "source": "K1 - in"},
]
}
}
result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1")
self.assertEqual(result, 99)
@patch("apps.operations.services.karung_web.date")
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_falls_back_to_today_live_rows(self, mock_fetch, mock_date):
mock_date.today.return_value = date(2026, 9, 2)
mock_fetch.return_value = {"masuk": {"history": [], "today": COMBINED_SAMPLE["masuk"]["today"]}}
result = fetch_iot_masuk_for_date(date(2026, 9, 2), kandang_name="Kandang 1")
self.assertEqual(result, 160)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_returns_none_when_day_missing(self, mock_fetch):
mock_fetch.return_value = COMBINED_SAMPLE
result = fetch_iot_masuk_for_date(date(2026, 8, 26), kandang_name="Kandang 1")
self.assertIsNone(result)
@patch(
"apps.operations.services.karung_web.KarungWebClient.fetch_combined",
side_effect=KarungWebError("upstream down"),
)
def test_fetch_iot_masuk_for_date_propagates_upstream_errors(self, _mock_fetch):
with self.assertRaises(KarungWebError):
fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1")