From 569d7c6bb66e67bc92a1a1307a66be0f944ba058 Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Tue, 1 Sep 2026 08:38:16 +0700 Subject: [PATCH] fix feed initial balance and report button --- backend/apps/farms/tests.py | 58 +++++++++++++++++++ backend/apps/farms/views.py | 12 ++-- .../apps/operations/services/feed_sacks.py | 32 ++++++++++ components/Dashboard.tsx | 4 +- components/FeedSackCountingPage.tsx | 12 +++- components/dashboard/DashboardReportCtas.tsx | 18 +++--- components/feedSack/FeedSaldoCard.tsx | 12 +++- components/feedSack/InitialBalanceCard.tsx | 4 +- context/FarmContext.tsx | 7 +++ 9 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 backend/apps/operations/services/feed_sacks.py diff --git a/backend/apps/farms/tests.py b/backend/apps/farms/tests.py index 006fd47..1cccff0 100644 --- a/backend/apps/farms/tests.py +++ b/backend/apps/farms/tests.py @@ -5,6 +5,7 @@ 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 class FarmAPITests(TestCase): @@ -85,3 +86,60 @@ class FarmAPITests(TestCase): self.assertEqual(response.status_code, 200, response.data) results = response.data["results"] if isinstance(response.data, dict) else response.data self.assertEqual(len(results), 2) + + def test_set_initial_balance_seeds_feed_sack_snapshot_when_none_exist(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, + ) + + 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) + + row = FeedSacks.objects.get(cycle=cycle) + self.assertEqual(row.date, start) + self.assertEqual(row.in_total, 120) + + def test_set_initial_balance_recomputes_existing_feed_sack_in_total(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, + ) + + 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, 124) diff --git a/backend/apps/farms/views.py b/backend/apps/farms/views.py index fe3e98a..158266a 100644 --- a/backend/apps/farms/views.py +++ b/backend/apps/farms/views.py @@ -10,6 +10,10 @@ from apps.accounts.models import User from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer from apps.operations.models import FeedSacks, ManualInput +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.manual_input import recompute_manual_totals @@ -145,12 +149,8 @@ class CycleViewSet(viewsets.ModelViewSet): recompute_manual_totals(cycle) # FeedSacks in_total always includes the initial balance as its base. - running = cycle.feed_initial_balance - for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"): - running += fs_row.in_today - if fs_row.in_total != running: - fs_row.in_total = running - fs_row.save(update_fields=["in_total", "updated_at"]) + 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: diff --git a/backend/apps/operations/services/feed_sacks.py b/backend/apps/operations/services/feed_sacks.py new file mode 100644 index 0000000..5fe255b --- /dev/null +++ b/backend/apps/operations/services/feed_sacks.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import date + +from apps.farms.models import Cycle +from apps.operations.models import FeedSacks + + +def recompute_feed_sack_in_totals(cycle: Cycle) -> None: + """Rebuild cumulative in_total from the cycle opening balance plus daily IoT in.""" + running = cycle.feed_initial_balance + for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"): + running += fs_row.in_today + if fs_row.in_total != running: + fs_row.in_total = running + fs_row.save(update_fields=["in_total", "updated_at"]) + + +def ensure_opening_feed_sack_snapshot(cycle: Cycle, balance_date: date) -> FeedSacks | None: + """Seed a zero-activity snapshot when opening balance is saved before any IoT sync.""" + if FeedSacks.objects.filter(cycle=cycle).exists(): + return None + return FeedSacks.objects.create( + cycle=cycle, + date=balance_date, + in_today=0, + out_today=0, + in_total=cycle.feed_initial_balance, + out_total=0, + feed_use_today=0, + feed_use_total=0, + ) diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index e95941f..da07760 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -161,9 +161,9 @@ const Dashboard: React.FC = ({ setView }) => { const feedTotal = kpis[0]?.feed_total ?? 0; const feedWeightKg = feedTotal * 50; - const feedInTotal = karung?.in_total ?? 0; - const feedOutTotal = karung?.out_total ?? 0; const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0; + const feedInTotal = karung?.in_total ?? feedInitialBalance; + const feedOutTotal = karung?.out_total ?? 0; const feedSaldo = feedInTotal - feedTotal - feedOutTotal; const feedSaldoColor = feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600'; diff --git a/components/FeedSackCountingPage.tsx b/components/FeedSackCountingPage.tsx index 4807689..30dab58 100644 --- a/components/FeedSackCountingPage.tsx +++ b/components/FeedSackCountingPage.tsx @@ -11,7 +11,8 @@ import FeedDailySummaryTable from './feedSack/FeedDailySummaryTable.tsx'; import InitialBalanceCard from './feedSack/InitialBalanceCard.tsx'; const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle, selectedKandang, selectedSite, cycles, setSelectedCycleId } = useFarm(); + const { selectedCycle, selectedKandang, selectedSite, cycles, setSelectedCycleId, updateCycle } = + useFarm(); const [rows, setRows] = useState([]); const [manualRows, setManualRows] = useState([]); const [insights, setInsights] = useState([]); @@ -274,7 +275,13 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings {/* Saldo Awal */} - reload(selectedCycle.id)} /> + { + updateCycle(saved.cycle, { feed_initial_balance: saved.feed_initial_balance }); + void reload(selectedCycle.id); + }} + /> {/* Ringkasan stats */} void }> = ({ onSettings {/* Saldo Karung */} diff --git a/components/dashboard/DashboardReportCtas.tsx b/components/dashboard/DashboardReportCtas.tsx index 58d31ba..8021d00 100644 --- a/components/dashboard/DashboardReportCtas.tsx +++ b/components/dashboard/DashboardReportCtas.tsx @@ -9,7 +9,7 @@ export interface DashboardReportCtasProps { } const buttonClass = - 'rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50'; + 'rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400 disabled:hover:bg-slate-400'; const label = (hasSnapshot: boolean) => (hasSnapshot ? 'Lihat' : 'Buat'); @@ -20,11 +20,11 @@ const DashboardReportCtas: React.FC = ({ onOpenDaily, onOpenEndCycle, }) => ( -
- -
+
+
+ - {!cycleClosed && ( -

Tersedia setelah siklus ditutup

- )}
+ {!cycleClosed && ( +

Tersedia setelah siklus ditutup

+ )}
); diff --git a/components/feedSack/FeedSaldoCard.tsx b/components/feedSack/FeedSaldoCard.tsx index 36d91f0..1c38bc6 100644 --- a/components/feedSack/FeedSaldoCard.tsx +++ b/components/feedSack/FeedSaldoCard.tsx @@ -4,18 +4,24 @@ import { formatNumber } from '../../utils/format.ts'; interface FeedSaldoCardProps { latest: Karung | null; + feedInitialBalance: number; cycleStartDate: string; displayDate: string; } -const FeedSaldoCard: React.FC = ({ latest, cycleStartDate, displayDate }) => { +const FeedSaldoCard: React.FC = ({ + latest, + feedInitialBalance, + cycleStartDate, + displayDate, +}) => { const saldoData = useMemo(() => { - const inbound = latest?.in_total ?? 0; + const inbound = latest?.in_total ?? feedInitialBalance; const feedUse = latest?.feed_use_total ?? 0; const keluar = latest?.out_total ?? 0; const saldo = inbound - feedUse - keluar; return { inbound, feedUse, keluar, saldo }; - }, [latest]); + }, [latest, feedInitialBalance]); const formatDateRange = useMemo(() => { const formatDate = (iso: string) => diff --git a/components/feedSack/InitialBalanceCard.tsx b/components/feedSack/InitialBalanceCard.tsx index 810d36c..5a2ae00 100644 --- a/components/feedSack/InitialBalanceCard.tsx +++ b/components/feedSack/InitialBalanceCard.tsx @@ -5,7 +5,7 @@ import { formatNumber } from '../../utils/format.ts'; interface InitialBalanceCardProps { cycle: Cycle; - onSaved: () => void; + onSaved: (result: InitialBalanceResult) => void; } const InitialBalanceCard: React.FC = ({ cycle, onSaved }) => { @@ -32,7 +32,7 @@ const InitialBalanceCard: React.FC = ({ cycle, onSaved feed_in_manual: Number(manualEntry), }); setResult(saved); - onSaved(); + onSaved(saved); } catch (err) { setError(errorMessage(err)); } finally { diff --git a/context/FarmContext.tsx b/context/FarmContext.tsx index 47ecb5b..32d4073 100644 --- a/context/FarmContext.tsx +++ b/context/FarmContext.tsx @@ -20,6 +20,7 @@ type FarmContextValue = { setSelectedKandangId: (id: number | null) => void; setSelectedCycleId: (id: number | null) => void; reload: () => Promise; + updateCycle: (cycleId: number, patch: Partial) => void; }; const FarmContext = createContext(null); @@ -41,6 +42,10 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children setReloadToken((token) => token + 1); }, []); + const updateCycle = useCallback((cycleId: number, patch: Partial) => { + setCycles((prev) => prev.map((c) => (c.id === cycleId ? { ...c, ...patch } : c))); + }, []); + useEffect(() => { if (!user) { setSites([]); @@ -175,6 +180,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children setSelectedKandangId, setSelectedCycleId, reload, + updateCycle, }), [ sites, @@ -190,6 +196,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children loading, error, reload, + updateCycle, ] );