fix initial balance logic and add edit for cycle data

This commit is contained in:
Alberto-Audrix committed 2026-09-01 14:59:47 +07:00
1 parent 7a9d600aa6
commit 697feb206c
15 files changed
+402 -93

No files matched your search

+19
View File
@@ -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
+14
View File
@@ -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)
+101 -7
View File
@@ -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):
+16 -19
View File
@@ -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,
+50 -11
View File
@@ -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()
@@ -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