diff --git a/backend/apps/operations/services/kpi_rollups.py b/backend/apps/operations/services/kpi_rollups.py index 4749b2a..54cd060 100644 --- a/backend/apps/operations/services/kpi_rollups.py +++ b/backend/apps/operations/services/kpi_rollups.py @@ -15,44 +15,56 @@ from apps.operations.services.kpi_formulas import ( compute_fcr, compute_stock_akhir, ) -from apps.operations.services.visibility import visible_through_date def refresh_kpi_source_fields(cycle: Cycle, *, through: date | None = None) -> int: """ Upsert one KPI row per day that has cc + cw + fs + manual. + If `through` is specified, rollup calculations are bounded up to that date. + Otherwise, rollups are calculated for all complete days of the cycle (up to `cycle.end_date` if set). + Dashboard cutoff visibility is enforced by the KPI API queryset layer (`VisibilityFilteredMixin`). + Returns the number of KPI rows written. """ - cutoff = through if through is not None else visible_through_date() + cutoff = through if through is not None else cycle.end_date start = cycle.start_date - if cycle.end_date and cycle.end_date < cutoff: + if through is not None and cycle.end_date and cycle.end_date < cutoff: cutoff = cycle.end_date + manual_filter: dict = {"cycle": cycle, "date__gte": start} + cc_filter: dict = {"cycle": cycle, "date__gte": start} + cw_filter: dict = {"cycle": cycle, "date__gte": start} + fs_filter: dict = {"cycle": cycle, "date__gte": start} + + if cutoff is not None: + manual_filter["date__lte"] = cutoff + cc_filter["date__lte"] = cutoff + cw_filter["date__lte"] = cutoff + fs_filter["date__lte"] = cutoff + manuals = { row.date: row - for row in ManualInput.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff).order_by( - "date", "pk" - ) + for row in ManualInput.objects.filter(**manual_filter).order_by("date", "pk") } countings = { row.date: row - for row in ChickenCounting.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff) + for row in ChickenCounting.objects.filter(**cc_filter) } weights = { row.date: row - for row in ChickenWeight.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff) + for row in ChickenWeight.objects.filter(**cw_filter) } feeds = { row.date: row - for row in FeedSacks.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff) + for row in FeedSacks.objects.filter(**fs_filter) } written = 0 harvest_age_sum = 0.0 for day in sorted(set(manuals) | set(countings) | set(weights) | set(feeds)): - if day < start or day > cutoff: + if day < start or (cutoff is not None and day > cutoff): continue manual = manuals.get(day) cc = countings.get(day) diff --git a/backend/apps/operations/tests_kpi_rollups.py b/backend/apps/operations/tests_kpi_rollups.py index e49d093..086aed2 100644 --- a/backend/apps/operations/tests_kpi_rollups.py +++ b/backend/apps/operations/tests_kpi_rollups.py @@ -121,3 +121,25 @@ class KpiRollupServiceTests(TestCase): refresh_kpi_source_fields(self.cycle, through=day) self.assertTrue(KPI.objects.filter(cycle=self.cycle, date=day).exists()) self.assertEqual(KPI.objects.get(cycle=self.cycle, date=day).mortality_total, 21) + + def test_manual_save_generates_kpi_without_explicit_through(self): + day = self.start + ChickenCounting.objects.create(cycle=self.cycle, date=day, total_count=1, mortality_count=0) + ChickenWeight.objects.create( + cycle=self.cycle, + date=day, + age=0, + doc_weight=40, + average_weight=35.0, + chicken_count=1, + uniformity=80, + average_daily_gain=0, + ) + FeedSacks.objects.create(cycle=self.cycle, date=day, feed_use_today=15, feed_use_total=15) + ManualInput.objects.create(cycle=self.cycle, date=day, mortality_manual=21) + recompute_manual_totals(self.cycle) + written = refresh_kpi_source_fields(self.cycle) + self.assertEqual(written, 1) + self.assertTrue(KPI.objects.filter(cycle=self.cycle, date=day).exists()) + self.assertEqual(KPI.objects.get(cycle=self.cycle, date=day).mortality_total, 21) +