kpi configuration (still prototype some data still use manual need to update later)

This commit is contained in:
Alberto-Audrix committed 2026-09-09 16:50:07 +07:00
1 parent e4dbfd0252
commit ed84d989b7
12 files changed
+547 -89

No files matched your search

@@ -1,58 +1,17 @@
from django.core.management.base import BaseCommand
from django.db.models import Sum
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, FeedSacks, KPI
from apps.operations.services.visibility import visible_through_date
from apps.operations.services.kpi_rollups import refresh_kpi_source_fields
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
help = "Recompute KPI source fields and FCR/EEF for active cycles"
def handle(self, *args, **options):
cutoff = visible_through_date()
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
def visible(model):
return model.objects.filter(cycle=cycle, date__lte=cutoff)
counting = visible(ChickenCounting).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = visible(FeedSacks).order_by("-date", "-pk").first()
latest_cc = visible(ChickenCounting).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = visible(ChickenWeight).order_by("-date").first()
latest_mi = visible(ManualInput).order_by("-date").first()
if not all([latest_cc, latest_cw, latest_fs, latest_mi]):
self.stdout.write(f"cycle={cycle.pk} skipped (missing source records)")
continue
kpi, _ = KPI.objects.update_or_create(
cycle=cycle,
date=latest_cc.date,
defaults={
"cc": latest_cc,
"cw": latest_cw,
"fs": latest_fs,
"manual": latest_mi,
"mortality_total": counting["mortality"] or 0,
"chicken_life": counting["life"] or cycle.doc_in_count,
"feed_total": feed_sack.feed_use_total if feed_sack else 0,
"chicken_life_percentage": (
((counting["life"] or cycle.doc_in_count) / cycle.doc_in_count * 100.0)
if cycle.doc_in_count
else 0.0
),
},
)
count += 1
self.stdout.write(f"KPI {kpi.pk} for cycle {cycle.pk}")
cycles = Cycle.objects.filter(status=Cycle.STATUS_ACTIVE)
for cycle in cycles:
written = refresh_kpi_source_fields(cycle)
count += written
self.stdout.write(f"cycle={cycle.pk} wrote {written} KPI row(s)")
self.stdout.write(self.style.SUCCESS(f"Recomputed {count} KPI row(s)"))
+2
View File
@@ -11,6 +11,7 @@ from apps.operations.models import (
ManualInput,
)
from apps.accounts.serializers import PkAsIdMixin
from apps.operations.services.kpi_rollups import refresh_kpi_source_fields
from apps.operations.services.manual_input import recompute_manual_totals
@@ -93,6 +94,7 @@ class ManualInputSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSer
defaults=defaults,
)
recompute_manual_totals(cycle)
refresh_kpi_source_fields(cycle)
row.refresh_from_db()
return row
@@ -0,0 +1,68 @@
"""Pure KPI / FCR / EEF formula helpers (Excel-correct)."""
from __future__ import annotations
def compute_chicken_life(doc_in_count: int, mortality_total: int) -> int:
return max(int(doc_in_count) - int(mortality_total), 0)
def compute_chicken_life_percentage(doc_in_count: int, chicken_life: int) -> float:
if not doc_in_count:
return 0.0
return (chicken_life / doc_in_count) * 100.0
def compute_stock_akhir(chicken_life: int, harvest_total: int) -> int:
return max(int(chicken_life) - int(harvest_total), 0)
def compute_fcr(
feed_total: int | float,
stock_akhir: int,
iot_weight: float,
harvest_weight_total_g: float,
) -> float:
"""FCR = (feed_total * 50 * 1000) / (stock_akhir * iot_weight + harvest_weight_total_g)."""
denom = (stock_akhir * iot_weight) + harvest_weight_total_g
if denom <= 0:
return 0.0
return (feed_total * 50 * 1000) / denom
def compute_average_harvest_weight(
harvest_weight_total_g: float,
stock_akhir: int,
iot_weight: float,
harvest_total: int,
) -> float:
"""Excel live-blend: (hwt_g + stock*iot) / (harvest_total + stock)."""
denom = harvest_total + stock_akhir
if denom <= 0:
return 0.0
return (harvest_weight_total_g + stock_akhir * iot_weight) / denom
def compute_average_harvest_day(
harvest_age_sum: float,
age: int,
stock_akhir: int,
harvest_total: int,
) -> float:
"""Excel live-blend: (Σ(age*harvest) + age*stock) / (harvest_total + stock)."""
denom = harvest_total + stock_akhir
if denom <= 0:
return 0.0
return (harvest_age_sum + age * stock_akhir) / denom
def compute_eef(
life_pct: float,
average_harvest_weight_g: float,
average_harvest_day: float,
fcr: float,
) -> float:
"""EEF = life_pct * (avg_bw_g/1000) / (avg_day * fcr) * 100."""
if average_harvest_day <= 0 or fcr <= 0:
return 0.0
return life_pct * (average_harvest_weight_g / 1000.0) / (average_harvest_day * fcr) * 100.0
@@ -0,0 +1,118 @@
"""Refresh daily KPI rows from ops sources and compute FCR / EEF."""
from __future__ import annotations
from datetime import date
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, ChickenWeight, FeedSacks, KPI, ManualInput
from apps.operations.services.kpi_formulas import (
compute_average_harvest_day,
compute_average_harvest_weight,
compute_chicken_life,
compute_chicken_life_percentage,
compute_eef,
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.
Returns the number of KPI rows written.
"""
cutoff = through if through is not None else visible_through_date()
start = cycle.start_date
if cycle.end_date and cycle.end_date < cutoff:
cutoff = cycle.end_date
manuals = {
row.date: row
for row in ManualInput.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff).order_by(
"date", "pk"
)
}
countings = {
row.date: row
for row in ChickenCounting.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff)
}
weights = {
row.date: row
for row in ChickenWeight.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff)
}
feeds = {
row.date: row
for row in FeedSacks.objects.filter(cycle=cycle, date__gte=start, date__lte=cutoff)
}
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:
continue
manual = manuals.get(day)
cc = countings.get(day)
cw = weights.get(day)
fs = feeds.get(day)
if not all([manual, cc, cw, fs]):
# Still accumulate harvest age sum from manual-only days so later
# complete days keep a correct running Σ(age×harvest).
if manual is not None:
age = (day - start).days
harvest_age_sum += age * (manual.harvest_manual or 0)
continue
age = (day - start).days
harvest_today = manual.harvest_manual or 0
harvest_age_sum += age * harvest_today
mortality_total = manual.mortality_manual_total or 0
harvest_total = manual.harvest_manual_total or 0
harvest_weight_total_g = float(manual.harvest_weight_manual_total or 0.0)
feed_total = fs.feed_use_total or 0
iot_weight = float(cw.average_weight or 0.0)
chicken_life = compute_chicken_life(cycle.doc_in_count, mortality_total)
life_pct = compute_chicken_life_percentage(cycle.doc_in_count, chicken_life)
stock_akhir = compute_stock_akhir(chicken_life, harvest_total)
avg_bw = compute_average_harvest_weight(
harvest_weight_total_g, stock_akhir, iot_weight, harvest_total
)
avg_day = compute_average_harvest_day(harvest_age_sum, age, stock_akhir, harvest_total)
fcr = compute_fcr(feed_total, stock_akhir, iot_weight, harvest_weight_total_g)
eef = compute_eef(life_pct, avg_bw, avg_day, fcr)
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"mortality": manual.mortality_manual or 0,
"mortality_total": mortality_total,
"feed": fs.feed_use_today or 0,
"feed_total": feed_total,
"harvest": harvest_today,
"harvest_total": harvest_total,
"harvest_weight": float(manual.harvest_weight_manual or 0.0),
"harvest_weight_total": harvest_weight_total_g,
"chicken_life": chicken_life,
"chicken_life_percentage": life_pct,
"iot_weight": iot_weight,
"average_harvest_day": avg_day,
"average_harvest_weight": avg_bw,
"fcr": fcr,
"eef": eef,
"cc": cc,
"cw": cw,
"fs": fs,
"manual": manual,
},
)
written += 1
return written
@@ -0,0 +1,163 @@
"""Excel-verified FCR/EEF formula tests (Sukawarna Kandang Atas days 30–35)."""
from django.test import SimpleTestCase
from apps.operations.services.kpi_formulas import (
compute_average_harvest_day,
compute_average_harvest_weight,
compute_chicken_life,
compute_chicken_life_percentage,
compute_eef,
compute_fcr,
compute_stock_akhir,
)
# From Laporan_Akhir-Siklus_Sukawarna_Kandang-Atas … FCR + EEF sheets.
# harvest_total / harvest_age_sum reconstructed so live-blend avgs match Excel.
EXCEL_DAYS = [
{
"age": 30,
"feed_total": 928,
"iot_weight": 1480.51,
"stock_akhir": 17658,
"harvest_weight_total_g": 6272.1 * 1000,
"harvest_total": 6400,
"life_pct": 96.232,
"excel_fcr": 1.4314384667247064,
"excel_avg_bw": 1347.3665965583173,
"excel_avg_day": 29.112145648017293,
"excel_eef": 311.14182093425177,
},
{
"age": 31,
"feed_total": 970,
"iot_weight": 1524.39,
"stock_akhir": 16419,
"harvest_weight_total_g": 7665.3 * 1000,
"harvest_total": 7600,
"life_pct": 96.076,
"excel_fcr": 1.4834408509392811,
"excel_avg_bw": 1361.183205379075,
"excel_avg_day": 29.844248303426454,
"excel_eef": 295.39328356931856,
},
{
"age": 32,
"feed_total": 1013,
"iot_weight": 1578.85,
"stock_akhir": 15929,
"harvest_weight_total_g": 8229.1 * 1000,
"harvest_total": 8045,
"life_pct": 95.896,
"excel_fcr": 1.5174392423955845,
"excel_avg_bw": 1392.2833757403853,
"excel_avg_day": 30.52506882455994,
"excel_eef": 288.2439276697344,
},
{
"age": 33,
"feed_total": 1031,
"iot_weight": 1609.98,
"stock_akhir": 10458,
"harvest_weight_total_g": 16476 * 1000,
"harvest_total": 13421,
"life_pct": 95.516,
"excel_fcr": 1.5474360050440639,
"excel_avg_bw": 1395.0823250554881,
"excel_avg_day": 31.18229406591566,
"excel_eef": 276.15645742250786,
},
{
"age": 34,
"feed_total": 1052,
"iot_weight": 1720.44,
"stock_akhir": 10347,
"harvest_weight_total_g": 16476 * 1000,
"harvest_total": 13421,
"life_pct": 95.072,
"excel_fcr": 1.5345391200273755,
"excel_avg_bw": 1442.1656294177044,
"excel_avg_day": 31.60913833726018,
"excel_eef": 282.668327683184,
},
{
"age": 35,
"feed_total": 1059,
"iot_weight": 1561.1,
"stock_akhir": 5198,
"harvest_weight_total_g": 25032.8 * 1000,
"harvest_total": 18413,
"life_pct": 94.444,
"excel_fcr": 1.5974104609804394,
"excel_avg_bw": 1403.8963957477447,
"excel_avg_day": 32.02481894032442,
"excel_eef": 259.1828280867179,
},
]
def _harvest_age_sum(row: dict) -> float:
"""Invert Excel avg_day blend: (sum + age*stock) / (h + stock) = avg_day."""
age = row["age"]
stock = row["stock_akhir"]
h = row["harvest_total"]
return row["excel_avg_day"] * (h + stock) - age * stock
class KpiFormulaTests(SimpleTestCase):
def test_chicken_life_excludes_harvest(self):
self.assertEqual(compute_chicken_life(25000, 942), 24058)
self.assertEqual(compute_chicken_life_percentage(25000, 24058), 96.232)
self.assertEqual(compute_stock_akhir(24058, 6400), 17658)
def test_fcr_matches_excel_days_30_to_35(self):
for row in EXCEL_DAYS:
with self.subTest(age=row["age"]):
got = compute_fcr(
row["feed_total"],
row["stock_akhir"],
row["iot_weight"],
row["harvest_weight_total_g"],
)
self.assertAlmostEqual(got, row["excel_fcr"], places=9)
def test_average_harvest_blend_matches_excel_days_30_to_35(self):
for row in EXCEL_DAYS:
with self.subTest(age=row["age"]):
avg_bw = compute_average_harvest_weight(
row["harvest_weight_total_g"],
row["stock_akhir"],
row["iot_weight"],
row["harvest_total"],
)
avg_day = compute_average_harvest_day(
_harvest_age_sum(row),
row["age"],
row["stock_akhir"],
row["harvest_total"],
)
self.assertAlmostEqual(avg_bw, row["excel_avg_bw"], places=6)
self.assertAlmostEqual(avg_day, row["excel_avg_day"], places=6)
def test_eef_matches_excel_days_30_to_35(self):
# Use Excel avg columns + computed FCR (same path as verified against the sheet).
for row in EXCEL_DAYS:
with self.subTest(age=row["age"]):
fcr = compute_fcr(
row["feed_total"],
row["stock_akhir"],
row["iot_weight"],
row["harvest_weight_total_g"],
)
got = compute_eef(
row["life_pct"],
row["excel_avg_bw"],
row["excel_avg_day"],
fcr,
)
self.assertAlmostEqual(got, row["excel_eef"], places=9)
def test_fcr_and_eef_zero_when_denom_invalid(self):
self.assertEqual(compute_fcr(100, 0, 0, 0), 0.0)
self.assertEqual(compute_eef(99.0, 1000.0, 0, 1.2), 0.0)
self.assertEqual(compute_eef(99.0, 1000.0, 10, 0), 0.0)
@@ -0,0 +1,123 @@
from datetime import date, timedelta
from django.test import TestCase, override_settings
from apps.accounts.models import User
from apps.farms.models import Cycle, Kandang, Site
from apps.operations.models import ChickenCounting, ChickenWeight, FeedSacks, KPI, ManualInput
from apps.operations.services.kpi_rollups import refresh_kpi_source_fields
from apps.operations.services.manual_input import recompute_manual_totals
@override_settings(DASHBOARD_PUBLISH_HOUR=0)
class KpiRollupServiceTests(TestCase):
def setUp(self):
user = User.objects.create_user(user_name="kpi-tester", password="secret")
site = Site.objects.create(site_name="Site", user=user)
kandang = Kandang.objects.create(kandang_name="K1", site=site)
self.start = date(2026, 5, 22)
self.cycle = Cycle.objects.create(
kandang=kandang,
total_days=40,
start_date=self.start,
end_date=self.start + timedelta(days=40),
doc_in_weight=40,
doc_in_count=25000,
status=Cycle.STATUS_ACTIVE,
)
def _sources(self, day: date, *, mort=10, harvest=0, harvest_wt_g=0.0, feed_use=15, weight=35.0):
ChickenCounting.objects.create(cycle=self.cycle, date=day, total_count=20000, mortality_count=mort)
ChickenWeight.objects.create(
cycle=self.cycle,
date=day,
age=(day - self.start).days,
doc_weight=40,
average_weight=weight,
chicken_count=100,
uniformity=80,
average_daily_gain=1,
)
FeedSacks.objects.create(
cycle=self.cycle,
date=day,
feed_use_today=feed_use,
feed_use_total=feed_use,
)
ManualInput.objects.create(
cycle=self.cycle,
date=day,
age_manual=(day - self.start).days,
mortality_manual=mort,
harvest_manual=harvest,
harvest_weight_manual=harvest_wt_g,
)
recompute_manual_totals(self.cycle)
def test_skips_day_when_weight_missing(self):
day = self.start
ChickenCounting.objects.create(cycle=self.cycle, date=day, total_count=1, mortality_count=0)
FeedSacks.objects.create(cycle=self.cycle, date=day, feed_use_today=1, feed_use_total=1)
ManualInput.objects.create(cycle=self.cycle, date=day, mortality_manual=5)
recompute_manual_totals(self.cycle)
written = refresh_kpi_source_fields(self.cycle, through=day)
self.assertEqual(written, 0)
self.assertFalse(KPI.objects.filter(cycle=self.cycle, date=day).exists())
def test_writes_kpi_with_chicken_life_and_fcr(self):
day0 = self.start
self._sources(day0, mort=21, feed_use=15, weight=35.0)
written = refresh_kpi_source_fields(self.cycle, through=day0)
self.assertEqual(written, 1)
kpi = KPI.objects.get(cycle=self.cycle, date=day0)
self.assertEqual(kpi.age, 0)
self.assertEqual(kpi.chicken_life, 25000 - 21)
self.assertAlmostEqual(kpi.chicken_life_percentage, (24979 / 25000) * 100, places=6)
self.assertEqual(kpi.iot_weight, 35.0)
self.assertEqual(kpi.feed_total, 15)
self.assertAlmostEqual(kpi.fcr, (15 * 50 * 1000) / (24979 * 35), places=9)
self.assertEqual(kpi.eef, 0.0) # age 0 → avg day blend is 0
def test_stock_akhir_used_in_fcr_after_harvest(self):
day0 = self.start
day1 = self.start + timedelta(days=1)
self._sources(day0, mort=21, feed_use=15, weight=35.0)
self._sources(day1, mort=30, harvest=100, harvest_wt_g=80_000.0, feed_use=20, weight=48.44)
fs1 = FeedSacks.objects.get(cycle=self.cycle, date=day1)
fs1.feed_use_total = 35
fs1.save(update_fields=["feed_use_total"])
recompute_manual_totals(self.cycle)
refresh_kpi_source_fields(self.cycle, through=day1)
kpi = KPI.objects.get(cycle=self.cycle, date=day1)
mort_total = 51
harvest_total = 100
chicken_life = 25000 - mort_total
stock = chicken_life - harvest_total
self.assertEqual(kpi.chicken_life, chicken_life)
self.assertEqual(kpi.harvest_total, harvest_total)
expected_fcr = (35 * 50 * 1000) / (stock * 48.44 + 80_000.0)
self.assertAlmostEqual(kpi.fcr, expected_fcr, places=9)
self.assertGreater(kpi.eef, 0)
def test_manual_save_refreshes_kpi_when_sources_complete(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)
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)