fix initial balance logic and add edit for cycle data
This commit is contained in:
1 parent
7a9d600aa6
commit
697feb206c
15 files changed
+402
-93
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user