diff --git a/backend/apps/farms/models.py b/backend/apps/farms/models.py index e22ea34..524457d 100644 --- a/backend/apps/farms/models.py +++ b/backend/apps/farms/models.py @@ -95,6 +95,25 @@ class Cycle(models.Model): if self.end_date and self.start_date and self.end_date < self.start_date: raise ValidationError({"end_date": "Must be on or after start_date"}) + def resolved_status(self) -> str: + """Close the cycle once dashboard data through the end date is visible.""" + from apps.operations.services.visibility import visible_through_date + + if visible_through_date() >= self.end_date: + return self.STATUS_CLOSED + return self.STATUS_ACTIVE + + def sync_status(self) -> bool: + resolved = self.resolved_status() + if self.status == resolved: + return False + self.status = resolved + return True + + def save(self, *args, **kwargs): + self.sync_status() + super().save(*args, **kwargs) + @property def current_day(self) -> int: from apps.operations.services.visibility import visible_through_date diff --git a/backend/apps/farms/serializers.py b/backend/apps/farms/serializers.py index 747625b..bec4d2d 100644 --- a/backend/apps/farms/serializers.py +++ b/backend/apps/farms/serializers.py @@ -56,6 +56,20 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer): "kandang_name", "feed_initial_balance", "feed_initial_balance_date", + "status", "created_at", "updated_at", ] + + def validate(self, attrs): + attrs.pop("status", None) + start = attrs.get("start_date", getattr(self.instance, "start_date", None)) + end = attrs.get("end_date", getattr(self.instance, "end_date", None)) + if start and end and end < start: + raise serializers.ValidationError({"end_date": "Must be on or after start_date."}) + return attrs + + def to_representation(self, instance): + if instance.sync_status(): + instance.save(update_fields=["status", "updated_at"]) + return super().to_representation(instance) diff --git a/backend/apps/farms/tests.py b/backend/apps/farms/tests.py index 46987f2..39c287c 100644 --- a/backend/apps/farms/tests.py +++ b/backend/apps/farms/tests.py @@ -61,6 +61,57 @@ class FarmAPITests(TestCase): self.assertNotIn("flock", response.data) self.assertNotIn("flock_name", response.data) + def test_update_cycle(self): + self.client.force_authenticate(self.user) + start = date(2026, 8, 1) + 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, + ) + new_end = start + timedelta(days=39) + response = self.client.patch( + f"/api/v1/cycles/{cycle.pk}/", + { + "end_date": new_end.isoformat(), + "total_days": 40, + "doc_in_count": 9500, + "chick_in_weight": 42, + }, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + cycle.refresh_from_db() + self.assertEqual(cycle.end_date, new_end) + self.assertEqual(cycle.total_days, 40) + self.assertEqual(cycle.doc_in_count, 9500) + self.assertEqual(cycle.doc_in_weight, 42) + + @patch("apps.operations.services.visibility.visible_through_date", return_value=date(2026, 9, 10)) + def test_cycle_status_closes_when_end_date_is_visible(self, _mock_visible): + self.client.force_authenticate(self.user) + start = date(2026, 8, 1) + 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, + ) + + response = self.client.get(f"/api/v1/cycles/{cycle.pk}/") + + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED) + cycle.refresh_from_db() + self.assertEqual(cycle.status, Cycle.STATUS_CLOSED) + def test_flock_persists_across_cycles(self): flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) start = date.today() @@ -88,7 +139,7 @@ class FarmAPITests(TestCase): 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): + def test_set_initial_balance_does_not_create_feed_sack_row(self): start = date.today() cycle = Cycle.objects.create( kandang=self.kandang, @@ -108,12 +159,12 @@ class FarmAPITests(TestCase): ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat()) + cycle.refresh_from_db() + self.assertEqual(cycle.feed_initial_balance, 120) + self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists()) + self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists()) - 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): + def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self): start = date.today() cycle = Cycle.objects.create( kandang=self.kandang, @@ -144,7 +195,50 @@ class FarmAPITests(TestCase): self.assertEqual(response.status_code, 200, response.data) feed_row.refresh_from_db() - self.assertEqual(feed_row.in_total, 124) + self.assertEqual(feed_row.in_total, 4) + + def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self): + 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, + ) + feed_row = FeedSacks.objects.create( + cycle=cycle, + date=start + timedelta(days=2), + in_today=6, + in_total=6, + out_today=0, + out_total=0, + feed_use_today=0, + feed_use_total=0, + ) + + self.client.force_authenticate(self.user) + first = self.client.post( + f"/api/v1/cycles/{cycle.pk}/initial-balance/", + {"date": start.isoformat(), "feed_in_manual": 120}, + format="json", + ) + self.assertEqual(first.status_code, 200, first.data) + second = self.client.post( + f"/api/v1/cycles/{cycle.pk}/initial-balance/", + {"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80}, + format="json", + ) + self.assertEqual(second.status_code, 200, second.data) + + cycle.refresh_from_db() + self.assertEqual(cycle.feed_initial_balance, 80) + self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1)) + self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1) + feed_row.refresh_from_db() + self.assertEqual(feed_row.in_total, 6) @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): diff --git a/backend/apps/farms/views.py b/backend/apps/farms/views.py index 98b1ed1..bd2cc08 100644 --- a/backend/apps/farms/views.py +++ b/backend/apps/farms/views.py @@ -6,16 +6,14 @@ from rest_framework.response import Response 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, + cleanup_opening_balance_feed_rows, recompute_feed_sack_in_totals, ) from apps.operations.services.karung_web import ( initial_balance_accuracy, pull_initial_balance_iot, ) -from apps.operations.services.manual_input import recompute_manual_totals class IsSuperAdminForWrite(permissions.BasePermission): @@ -113,7 +111,6 @@ class CycleViewSet(viewsets.ModelViewSet): ) 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, @@ -125,7 +122,7 @@ class CycleViewSet(viewsets.ModelViewSet): else None ), "manual": feed_in_manual, - "manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual, + "manual_total": feed_in_manual, "iot": iot_in, "accuracy": initial_balance_accuracy(feed_in_manual, iot_in), "sync_error": sync_error, @@ -137,9 +134,10 @@ class CycleViewSet(viewsets.ModelViewSet): """Save the feed-in balance for the cycle (Saldo Awal). 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 - karung-web-admin ``/api/combined`` and returns a manual vs IoT accuracy figure. + feature writes — never during cycle creation). Opening balance is kept on + the cycle for saldo only and is not written into daily FeedSacks rows. + Pulls the IoT-detected "in" for that date from karung-web-admin + ``/api/combined`` and returns a manual vs IoT accuracy figure. """ cycle = self.get_object() @@ -166,6 +164,9 @@ class CycleViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) + previous_date = cycle.feed_initial_balance_date + previous_balance = cycle.feed_initial_balance + cycle.feed_initial_balance = feed_in_manual cycle.feed_initial_balance_date = balance_date cycle.save( @@ -175,21 +176,17 @@ class CycleViewSet(viewsets.ModelViewSet): # 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( - cycle=cycle, - date=balance_date, - defaults={"feed_in_manual": feed_in_manual}, + cleanup_opening_balance_feed_rows( + cycle, + previous_date=previous_date, + previous_balance=previous_balance, + balance_date=balance_date, + balance_amount=feed_in_manual, ) - recompute_manual_totals(cycle) - - # FeedSacks in_total always includes the initial balance as its base. recompute_feed_sack_in_totals(cycle) - ensure_opening_feed_sack_snapshot(cycle, balance_date) accuracy = initial_balance_accuracy(feed_in_manual, iot_in) - manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first() return Response( { "cycle": cycle.pk, @@ -201,7 +198,7 @@ class CycleViewSet(viewsets.ModelViewSet): else None ), "manual": feed_in_manual, - "manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual, + "manual_total": feed_in_manual, "iot": iot_in, "accuracy": accuracy, "sync_error": sync_error, diff --git a/backend/apps/operations/services/feed_sacks.py b/backend/apps/operations/services/feed_sacks.py index 5fe255b..13d71e4 100644 --- a/backend/apps/operations/services/feed_sacks.py +++ b/backend/apps/operations/services/feed_sacks.py @@ -7,8 +7,8 @@ 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 + """Rebuild cumulative in_total from daily IoT feed-in only (excludes opening balance).""" + running = 0 for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"): running += fs_row.in_today if fs_row.in_total != running: @@ -16,17 +16,56 @@ def recompute_feed_sack_in_totals(cycle: Cycle) -> None: 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( +def remove_opening_balance_feed_row( + cycle: Cycle, + *, + balance_date: date | None, + balance_amount: int, +) -> None: + """Drop a FeedSacks row that only existed to carry opening balance.""" + if balance_date is None or balance_amount <= 0: + return + FeedSacks.objects.filter( cycle=cycle, date=balance_date, - in_today=0, + in_today=balance_amount, out_today=0, - in_total=cycle.feed_initial_balance, - out_total=0, feed_use_today=0, + out_total=0, feed_use_total=0, - ) + ).delete() + + +def cleanup_opening_balance_feed_rows( + cycle: Cycle, + *, + previous_date: date | None, + previous_balance: int, + balance_date: date, + balance_amount: int, +) -> None: + """Remove opening-balance snapshot rows for the previous and current save.""" + for row_date, amount in ( + (previous_date, previous_balance), + (balance_date, balance_amount), + ): + remove_opening_balance_feed_row( + cycle, + balance_date=row_date, + balance_amount=amount, + ) + + # Legacy cleanup: drop opening-only rows left from earlier saves on other dates. + opening_amounts = { + amount for amount in (previous_balance, balance_amount) if amount > 0 + } + if not opening_amounts: + return + FeedSacks.objects.filter( + cycle=cycle, + in_today__in=opening_amounts, + out_today=0, + feed_use_today=0, + out_total=0, + feed_use_total=0, + ).delete() diff --git a/backend/apps/operations/services/karung_web.py b/backend/apps/operations/services/karung_web.py index 9bfcec4..b153930 100644 --- a/backend/apps/operations/services/karung_web.py +++ b/backend/apps/operations/services/karung_web.py @@ -146,7 +146,7 @@ def request_karung( out_total = prior.out_total + out_today feed_use_total = prior.feed_use_total + feed_use_today else: - in_total = cycle.feed_initial_balance + in_today + in_total = in_today out_total = out_today feed_use_total = feed_use_today diff --git a/components/CycleTimeline.tsx b/components/CycleTimeline.tsx index 88edd79..d04c1aa 100644 --- a/components/CycleTimeline.tsx +++ b/components/CycleTimeline.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useFarm } from '../context/FarmContext.tsx'; -import { formatDateId, formatDateRange } from '../utils/format.ts'; +import { formatDateId, formatDateRange, resolveCycleStatus } from '../utils/format.ts'; import type { Cycle } from '../types/api.ts'; type Phase = { name: string; startDay: number; endDay: number; color: string }; @@ -43,6 +43,7 @@ const statusConfig = { const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => { const phases = phasesFor(cycle.total_days); const currentDay = Math.min(Math.max(cycle.current_day, 0), cycle.total_days); + const cycleStatus = resolveCycleStatus(cycle); const progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0; return ( @@ -73,7 +74,7 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => { ); })} - {cycle.status === 'active' && currentDay > 0 && ( + {cycleStatus === 'active' && currentDay > 0 && (
{
)}
diff --git a/components/Header.tsx b/components/Header.tsx index f36d845..b6dae1a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useAuth } from '../context/AuthContext.tsx'; import { useFarm } from '../context/FarmContext.tsx'; -import { formatDateId } from '../utils/format.ts'; +import { formatDateId, resolveCycleStatus } from '../utils/format.ts'; type HeaderProps = { onToggleSidebar: () => void; @@ -93,7 +93,7 @@ const Header: React.FC = ({ {cycles.map((cycle) => ( ))} diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index b52f7e7..c675a99 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -2,12 +2,27 @@ import React, { useState } from 'react'; import { api, errorMessage } from '../services/apiClient.ts'; import { useAuth } from '../context/AuthContext.tsx'; import { useFarm } from '../context/FarmContext.tsx'; +import type { Cycle } from '../types/api.ts'; import { Banner, PageHeader } from './ui/Feedback.tsx'; -import { addDaysIso, formatDateId, inclusiveDayCount, todayIso } from '../utils/format.ts'; +import { addDaysIso, formatDateId, inclusiveDayCount, resolveCycleStatus, todayIso } from '../utils/format.ts'; const inputClass = 'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500'; +const defaultCycleForm = () => ({ + startDate: todayIso(), + endDate: addDaysIso(todayIso(), 34), + docInCount: 20000, + chickInWeight: 35, +}); + +const cycleToForm = (cycle: Cycle) => ({ + startDate: cycle.start_date, + endDate: cycle.end_date, + docInCount: cycle.doc_in_count, + chickInWeight: cycle.chick_in_weight, +}); + const SettingsPage: React.FC = () => { const { user } = useAuth(); const { @@ -28,10 +43,39 @@ const SettingsPage: React.FC = () => { const [siteName, setSiteName] = useState(''); const [kandangName, setKandangName] = useState(''); const [flockName, setFlockName] = useState(''); - const [startDate, setStartDate] = useState(todayIso()); - const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34)); - const [docInCount, setDocInCount] = useState(20000); - const [chickInWeight, setChickInWeight] = useState(35); + const [editingCycleId, setEditingCycleId] = useState(null); + const [startDate, setStartDate] = useState(defaultCycleForm().startDate); + const [endDate, setEndDate] = useState(defaultCycleForm().endDate); + const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount); + const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight); + + const resetCycleForm = () => { + const defaults = defaultCycleForm(); + setEditingCycleId(null); + setStartDate(defaults.startDate); + setEndDate(defaults.endDate); + setDocInCount(defaults.docInCount); + setChickInWeight(defaults.chickInWeight); + }; + + const beginCycleEdit = (cycle: Cycle) => { + const form = cycleToForm(cycle); + setEditingCycleId(cycle.id); + setStartDate(form.startDate); + setEndDate(form.endDate); + setDocInCount(form.docInCount); + setChickInWeight(form.chickInWeight); + setSelectedCycleId(cycle.id); + }; + + const buildCyclePayload = () => ({ + kandang: selectedKandangId!, + total_days: inclusiveDayCount(startDate, endDate), + start_date: startDate, + end_date: endDate, + chick_in_weight: chickInWeight, + doc_in_count: docInCount, + }); const flash = (msg: string) => { setNotice(msg); @@ -262,19 +306,24 @@ const SettingsPage: React.FC = () => { e.preventDefault(); if (!selectedKandangId) return; void wrap(async () => { - await api.cycles.create({ - kandang: selectedKandangId, - total_days: inclusiveDayCount(startDate, endDate), - current_day: 1, - start_date: startDate, - end_date: endDate, - chick_in_weight: chickInWeight, - doc_in_count: docInCount, - }); - flash('Siklus dibuat.'); + const payload = buildCyclePayload(); + if (editingCycleId) { + await api.cycles.update(editingCycleId, payload); + flash('Siklus diperbarui.'); + resetCycleForm(); + } else { + await api.cycles.create({ ...payload, current_day: 1 }); + flash('Siklus dibuat.'); + resetCycleForm(); + } }); }} > + {editingCycleId && ( +

+ Mengedit siklus {formatDateId(startDate)} – {formatDateId(endDate)} +

+ )} - +
+ + {editingCycleId && ( + + )} +