update add feed initial balance on cycle and fix its feature
This commit is contained in:
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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user