fix feed initial balance and report button
This commit is contained in:
1 parent
30a736641f
commit
569d7c6bb6
9 files changed
+135
-24
No files matched your search
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -161,9 +161,9 @@ const Dashboard: React.FC<Props> = ({ 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';
|
||||
|
||||
@@ -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<Karung[]>([]);
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
@@ -274,7 +275,13 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
</section>
|
||||
|
||||
{/* Saldo Awal */}
|
||||
<InitialBalanceCard cycle={selectedCycle} onSaved={() => reload(selectedCycle.id)} />
|
||||
<InitialBalanceCard
|
||||
cycle={selectedCycle}
|
||||
onSaved={(saved) => {
|
||||
updateCycle(saved.cycle, { feed_initial_balance: saved.feed_initial_balance });
|
||||
void reload(selectedCycle.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Ringkasan stats */}
|
||||
<FeedStats
|
||||
@@ -287,6 +294,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
{/* Saldo Karung */}
|
||||
<FeedSaldoCard
|
||||
latest={latest}
|
||||
feedInitialBalance={selectedCycle.feed_initial_balance}
|
||||
cycleStartDate={selectedCycle.start_date}
|
||||
displayDate={displayDate}
|
||||
/>
|
||||
|
||||
@@ -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<DashboardReportCtasProps> = ({
|
||||
onOpenDaily,
|
||||
onOpenEndCycle,
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-end justify-end gap-3">
|
||||
<button type="button" onClick={onOpenDaily} className={buttonClass}>
|
||||
{label(hasDailySnapshot)} Harian
|
||||
</button>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<button type="button" onClick={onOpenDaily} className={buttonClass}>
|
||||
{label(hasDailySnapshot)} Harian
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEndCycle}
|
||||
@@ -33,10 +33,10 @@ const DashboardReportCtas: React.FC<DashboardReportCtasProps> = ({
|
||||
>
|
||||
{label(hasEndCycleSnapshot)} Akhir Siklus
|
||||
</button>
|
||||
{!cycleClosed && (
|
||||
<p className="mt-1 text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
|
||||
)}
|
||||
</div>
|
||||
{!cycleClosed && (
|
||||
<p className="text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<FeedSaldoCardProps> = ({ latest, cycleStartDate, displayDate }) => {
|
||||
const FeedSaldoCard: React.FC<FeedSaldoCardProps> = ({
|
||||
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) =>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatNumber } from '../../utils/format.ts';
|
||||
|
||||
interface InitialBalanceCardProps {
|
||||
cycle: Cycle;
|
||||
onSaved: () => void;
|
||||
onSaved: (result: InitialBalanceResult) => void;
|
||||
}
|
||||
|
||||
const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved }) => {
|
||||
@@ -32,7 +32,7 @@ const InitialBalanceCard: React.FC<InitialBalanceCardProps> = ({ cycle, onSaved
|
||||
feed_in_manual: Number(manualEntry),
|
||||
});
|
||||
setResult(saved);
|
||||
onSaved();
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
||||
@@ -20,6 +20,7 @@ type FarmContextValue = {
|
||||
setSelectedKandangId: (id: number | null) => void;
|
||||
setSelectedCycleId: (id: number | null) => void;
|
||||
reload: () => Promise<void>;
|
||||
updateCycle: (cycleId: number, patch: Partial<Cycle>) => void;
|
||||
};
|
||||
|
||||
const FarmContext = createContext<FarmContextValue | null>(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<Cycle>) => {
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user