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
@@ -105,7 +105,7 @@ Login, Dashboard (KPI + AI insight + Total Pakan section + in-memory Harian/Akhi
|
||||
|
||||
Reports are generated in the browser from live `/api/v1/` data and exportable as **PDF** (multi-page A4, block-aware page breaks, compressed JPEG) and **Excel** (Chicken Counting, Weight, Feed In, Feed Use, Feed Out, FCR, EEF sheets with IoT-vs-manual accuracy). Closing the overlay drops the result; nothing is saved on Django.
|
||||
|
||||
The **Saldo Awal** feature on Hitung Karung posts `feed_initial_balance` to the cycle and compares the manual feed-in against the IoT-detected "in" for that date with an accuracy figure.
|
||||
The **Saldo Awal** feature on Hitung Karung posts `feed_initial_balance` to the cycle and compares the manual feed-in against the IoT-detected "in" for that date (from karung-web-admin `GET /api/combined`) with an accuracy figure.
|
||||
|
||||
Karung Sync calls karung-web-admin. If that host is down the API often returns 502; stored rows still render.
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ See [../DOCKER.md](../DOCKER.md) for ports, cron, and troubleshooting.
|
||||
## External integrations
|
||||
|
||||
- karung-web-admin → `POST /api/v1/karungs/request/` (`KARUNG_WEB_ADMIN_BASE_URL`)
|
||||
- Saldo Awal IoT compare → karung-web-admin `GET /api/combined`
|
||||
|
||||
## Docs
|
||||
|
||||
|
||||
@@ -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")
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 300 KiB |
@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS cycles (
|
||||
doc_in_weight INTEGER NOT NULL,
|
||||
doc_in_count INTEGER NOT NULL,
|
||||
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
|
||||
feed_initial_balance_date DATE,
|
||||
status VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -278,7 +278,10 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<InitialBalanceCard
|
||||
cycle={selectedCycle}
|
||||
onSaved={(saved) => {
|
||||
updateCycle(saved.cycle, { feed_initial_balance: saved.feed_initial_balance });
|
||||
updateCycle(saved.cycle, {
|
||||
feed_initial_balance: saved.feed_initial_balance,
|
||||
feed_initial_balance_date: saved.feed_initial_balance_date,
|
||||
});
|
||||
void reload(selectedCycle.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, 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 InitialBalanceComparison from './InitialBalanceComparison.tsx';
|
||||
|
||||
interface InitialBalanceCardProps {
|
||||
cycle: Cycle;
|
||||
@@ -9,19 +10,59 @@ interface InitialBalanceCardProps {
|
||||
}
|
||||
|
||||
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved }) => {
|
||||
const [balanceDate, setBalanceDate] = useState(cycle.start_date);
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<InitialBalanceResult | 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 canSave = useMemo(
|
||||
() => balanceDate !== '' && manualEntry !== '' && Number(manualEntry) >= 0 && !saving,
|
||||
[balanceDate, manualEntry, saving]
|
||||
);
|
||||
|
||||
const loadComparison = useCallback(async () => {
|
||||
if (balanceDate === '' || manualValue == null || Number.isNaN(manualValue) || manualValue < 0) {
|
||||
setComparison(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setComparing(true);
|
||||
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 handleSave = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
@@ -31,7 +72,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
|
||||
date: balanceDate,
|
||||
feed_in_manual: Number(manualEntry),
|
||||
});
|
||||
setResult(saved);
|
||||
setComparison(saved);
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
@@ -40,17 +81,6 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
|
||||
}
|
||||
};
|
||||
|
||||
const accuracy = result?.accuracy;
|
||||
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
|
||||
const accuracyColor =
|
||||
accuracy == null
|
||||
? 'text-gray-500'
|
||||
: accuracy >= 95
|
||||
? 'text-green-600'
|
||||
: accuracy >= 80
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600';
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl shadow-sm border-2 border-blue-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -61,7 +91,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
|
||||
<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.
|
||||
IoT dari karung-web-admin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,16 +139,27 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
<i className={`fa-solid fa-floppy-disk ${saving ? 'fa-spin' : ''}`} />
|
||||
<span>{saving ? 'Menyimpan...' : 'Simpan Saldo Awal'}</span>
|
||||
</button>
|
||||
{cycle.feed_initial_balance > 0 && result == null && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
<i className={`fa-solid fa-floppy-disk ${saving ? 'fa-spin' : ''}`} />
|
||||
<span>{saving ? 'Menyimpan...' : 'Simpan Saldo Awal'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadComparison()}
|
||||
disabled={manualValue == null || manualValue < 0 || comparing}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-blue-300 bg-white px-4 py-2 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
>
|
||||
<i className={`fa-solid fa-satellite-dish ${comparing ? 'fa-spin' : ''}`} />
|
||||
<span>{comparing ? 'Memuat IoT...' : 'Refresh IoT'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{cycle.feed_initial_balance > 0 && comparison == null && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Saldo awal tersimpan:{' '}
|
||||
<strong className="text-blue-700">
|
||||
@@ -128,34 +169,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<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}</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>
|
||||
</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`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-gray-600">Akurasi</p>
|
||||
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.sync_error && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{comparison && <InitialBalanceComparison result={comparison} loading={comparing} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import type { InitialBalanceResult } from '../../types/api.ts';
|
||||
import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface InitialBalanceComparisonProps {
|
||||
result: InitialBalanceResult;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const InitialBalanceComparison: React.FC<InitialBalanceComparisonProps> = ({
|
||||
result,
|
||||
loading = false,
|
||||
}) => {
|
||||
const accuracy = result.accuracy;
|
||||
const accuracyLabel = accuracy == null ? 'N/A' : `${formatNumber(accuracy, 1)}%`;
|
||||
const accuracyColor =
|
||||
accuracy == null
|
||||
? 'text-gray-500'
|
||||
: accuracy >= 95
|
||||
? 'text-green-600'
|
||||
: accuracy >= 80
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600';
|
||||
|
||||
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...' : ''}
|
||||
</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>
|
||||
</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`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white border border-gray-200 p-3">
|
||||
<p className="text-xs font-semibold text-gray-600">Akurasi</p>
|
||||
<p className={`text-2xl font-bold ${accuracyColor}`}>{accuracyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.sync_error && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialBalanceComparison;
|
||||
@@ -178,6 +178,10 @@ export const api = {
|
||||
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),
|
||||
},
|
||||
kpis: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
|
||||
@@ -53,6 +53,7 @@ export type Cycle = {
|
||||
chick_in_weight: number;
|
||||
doc_in_count: number;
|
||||
feed_initial_balance: number;
|
||||
feed_initial_balance_date?: string | null;
|
||||
status: CycleStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -232,6 +233,7 @@ export type CycleWrite = {
|
||||
chick_in_weight: number;
|
||||
doc_in_count: number;
|
||||
feed_initial_balance?: number;
|
||||
feed_initial_balance_date?: string | null;
|
||||
status?: CycleStatus;
|
||||
};
|
||||
|
||||
@@ -239,6 +241,7 @@ 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;
|
||||
|
||||
Reference in new issue
Block a user