diff --git a/DOCKER.md b/DOCKER.md index 981b1dc..305056c 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -89,7 +89,7 @@ Open **http://localhost** in a browser. | `nginx.conf` | Nginx proxy config for frontend | | `entrypoint-api.sh` | API container startup | | `entrypoint-cron.sh` | Cron container startup | -| `crontab` | Scheduled jobs (IoT 10 min, karung/KPI 17:00) | +| `crontab` | Fallback schedule; runtime regenerated from `DASHBOARD_PUBLISH_*` | | `.env.example` | Compose env template → copy to project root `.env` | | `compose.override.local.example` | Optional local Postgres port override | @@ -166,10 +166,10 @@ Adds `127.0.0.1:5432:5432` while keeping the server’s `15432` mapping. The `cron` service runs: -- **Every 10 min** — `sync_iot_from_api` (IoT panel, real-time — not gated by 17:00) -- **17:00 daily** — `sync_karung_from_web_admin` then `recompute_kpi_rollups` (feed use counts until 16:59, so karung sync runs at publish time) +- **Every 10 min** — `sync_iot_from_api` (IoT panel, real-time — not gated by publish cutoff) +- **At `DASHBOARD_PUBLISH_HOUR`:`DASHBOARD_PUBLISH_MINUTE` daily** — `sync_karung_from_web_admin` then `recompute_kpi_rollups` (entrypoint renders cron from these env vars; default 17:00) -Dashboard reads hide today's KPI/counting/weight/karung data until 17:00 WIB. **IoT panel is excluded** and updates live every 10 minutes. +Dashboard reads hide today's KPI/counting/weight/karung data until the configured publish time WIB. **IoT panel is excluded** and updates live every 10 minutes. Logs: `docker compose exec cron tail -f /var/log/cron.log` diff --git a/backend/apps/jobs/management/commands/recompute_kpi_rollups.py b/backend/apps/jobs/management/commands/recompute_kpi_rollups.py index 0277c1e..95f9cea 100644 --- a/backend/apps/jobs/management/commands/recompute_kpi_rollups.py +++ b/backend/apps/jobs/management/commands/recompute_kpi_rollups.py @@ -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)")) diff --git a/backend/apps/operations/serializers.py b/backend/apps/operations/serializers.py index 17a68bb..0affb12 100644 --- a/backend/apps/operations/serializers.py +++ b/backend/apps/operations/serializers.py @@ -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 diff --git a/backend/apps/operations/services/kpi_formulas.py b/backend/apps/operations/services/kpi_formulas.py new file mode 100644 index 0000000..1b6821d --- /dev/null +++ b/backend/apps/operations/services/kpi_formulas.py @@ -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 diff --git a/backend/apps/operations/services/kpi_rollups.py b/backend/apps/operations/services/kpi_rollups.py new file mode 100644 index 0000000..4749b2a --- /dev/null +++ b/backend/apps/operations/services/kpi_rollups.py @@ -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 diff --git a/backend/apps/operations/tests_kpi_formulas.py b/backend/apps/operations/tests_kpi_formulas.py new file mode 100644 index 0000000..1b4cb7d --- /dev/null +++ b/backend/apps/operations/tests_kpi_formulas.py @@ -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) diff --git a/backend/apps/operations/tests_kpi_rollups.py b/backend/apps/operations/tests_kpi_rollups.py new file mode 100644 index 0000000..e49d093 --- /dev/null +++ b/backend/apps/operations/tests_kpi_rollups.py @@ -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) diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 116e49b..43f3afd 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -6,7 +6,6 @@ import type { ChickenWeight, Karung, KPI, - LatestAvg, ManualInput, } from '../types/api.ts'; import type { View } from '../types/common.ts'; @@ -20,7 +19,7 @@ import { formatNumber, resolveCycleStatus, } from '../utils/format.ts'; -import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../utils/visibility.ts'; +import { throughVisibleDate } from '../utils/visibility.ts'; import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'; import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx'; @@ -43,7 +42,6 @@ const Dashboard: React.FC = ({ setView }) => { const [manualInputs, setManualInputs] = useState([]); const [weights, setWeights] = useState([]); const [karung, setKarung] = useState(null); - const [latestAvg, setLatestAvg] = useState(null); const [insights, setInsights] = useState([]); const [reportOpen, setReportOpen] = useState(false); const [reportType, setReportType] = useState('daily'); @@ -55,7 +53,6 @@ const Dashboard: React.FC = ({ setView }) => { setManualInputs([]); setWeights([]); setKarung(null); - setLatestAvg(null); setInsights([]); return; } @@ -78,19 +75,12 @@ const Dashboard: React.FC = ({ setView }) => { } catch (err) { if (!(err instanceof ApiError && err.status === 404)) throw err; } - let avg: LatestAvg | null = null; - try { - avg = await api.weights.latestAvg({ cycle_id: cycleId }); - } catch (err) { - if (!(err instanceof ApiError && err.status === 404)) throw err; - } if (cancelled) return; setKpis(kpiRows); setCountings(countingRows); setManualInputs(manualRows); setWeights(weightRows); setKarung(latestKarung); - setLatestAvg(avg); setInsights(insightRows); } catch (err) { if (!cancelled) setError(errorMessage(err)); @@ -104,40 +94,51 @@ const Dashboard: React.FC = ({ setView }) => { }, [selectedCycle]); const cards: KpiCardModel[] = useMemo(() => { - // Countings/weights APIs are real-time; KPI cards still honor publish cutoff. + // All cards share the latest complete KPI day (not calendar current_day / live IoT). const visibleThrough = selectedCycle?.visible_through_date; const visibleKpis = throughVisibleDate(kpis, visibleThrough); - const visibleCountings = throughVisibleDate(countings, visibleThrough); - const visibleWeights = throughVisibleDate(weights, visibleThrough); - const visibleLatestAvg = - latestAvg && isOnOrBeforeVisibleDate(latestAvg.date, visibleThrough) ? latestAvg : null; - const visibleKarung = - karung && isOnOrBeforeVisibleDate(karung.date, visibleThrough) ? karung : null; + const latest = visibleKpis[0] ?? null; + const anchorDate = latest?.date ?? null; + + const kpisThroughAnchor = anchorDate + ? visibleKpis.filter((row) => row.date <= anchorDate) + : []; + const weightsThroughAnchor = anchorDate + ? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate) + : []; + const weightOnAnchor = anchorDate + ? weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0] + : null; - const latest = visibleKpis[0]; const startDate = selectedCycle?.start_date; const trendLabel = (isoDate: string): string => { const day = startDate ? cycleDayForDate(startDate, isoDate) : 0; return `Hari ${day} · ${formatDateId(isoDate)}`; }; - const fcrTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({ + const fcrTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({ value: row.fcr, label: trendLabel(row.date), })); - const eefTrend: KpiTrendPoint[] = chronological(visibleKpis).map((row) => ({ + const eefTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({ value: row.eef, label: trendLabel(row.date), })); - const mortalityTrend: KpiTrendPoint[] = chronological(visibleCountings).map((row) => ({ - value: row.mortality_count, + const mortalityTrend: KpiTrendPoint[] = chronological(kpisThroughAnchor).map((row) => ({ + value: row.mortality, label: trendLabel(row.date), })); - const mortalityToday = latest?.mortality ?? visibleCountings[0]?.mortality_count ?? null; - const weightTrend: KpiTrendPoint[] = chronological(visibleWeights).map((row) => ({ + const weightTrend: KpiTrendPoint[] = chronological(weightsThroughAnchor).map((row) => ({ value: row.average_weight, label: trendLabel(row.date), })); - const age = selectedCycle?.current_day ?? latest?.age ?? null; + const age = latest?.age ?? null; + const mortalityToday = latest?.mortality ?? null; + const feedToday = latest != null ? latest.feed : null; + const weightToday = + latest && latest.iot_weight > 0 + ? latest.iot_weight + : (weightOnAnchor?.average_weight ?? null); + return [ { key: 'umur', @@ -160,20 +161,20 @@ const Dashboard: React.FC = ({ setView }) => { { key: 'karung', title: 'Karung Dituang', - value: visibleKarung?.feed_use_today ?? visibleKarung?.out_today ?? null, + value: feedToday, unit: 'karung', icon: '🛢️', - status: visibleKarung ? 'ok' : 'na', + status: feedToday == null ? 'na' : 'ok', targetView: 'feedSackCounting', }, { key: 'weight', title: 'Berat AVG', - value: visibleLatestAvg?.average_weight ?? visibleWeights[0]?.average_weight ?? null, + value: weightToday, unit: 'gram', icon: '⚖️', trend: weightTrend, - status: visibleLatestAvg || visibleWeights[0] ? 'ok' : 'na', + status: weightToday == null ? 'na' : 'ok', targetView: 'weightMonitoring', }, { @@ -197,7 +198,7 @@ const Dashboard: React.FC = ({ setView }) => { targetView: 'eefDashboard', }, ]; - }, [kpis, countings, weights, karung, latestAvg, selectedCycle]); + }, [kpis, weights, selectedCycle]); const countingAccuracyWarning = useMemo(() => { if (!selectedCycle) return null; diff --git a/components/FcrDashboardPage.tsx b/components/FcrDashboardPage.tsx index 1824d7e..06ca354 100644 --- a/components/FcrDashboardPage.tsx +++ b/components/FcrDashboardPage.tsx @@ -230,8 +230,8 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) .map((row) => ({ day: row.age, feedTotal: row.feed_total, - iotWeight: weightByAge.get(row.age) ?? null, - chickenLife: row.chicken_life, + iotWeight: row.iot_weight > 0 ? row.iot_weight : weightByAge.get(row.age) ?? null, + chickenLife: Math.max(row.chicken_life - row.harvest_total, 0), harvestWeightKg: row.harvest_weight_total / 1000, fcr: row.fcr > 0 ? row.fcr : null, })), diff --git a/components/dashboard/DashboardManualInputForm.tsx b/components/dashboard/DashboardManualInputForm.tsx index 39be394..92a5e94 100644 --- a/components/dashboard/DashboardManualInputForm.tsx +++ b/components/dashboard/DashboardManualInputForm.tsx @@ -151,7 +151,7 @@ const DashboardManualInputForm: React.FC = ({ cycleId, currentDay, readOn

{readOnly ? 'Siklus ini sudah ditutup. Data di bawah hanya untuk ditinjau.' - : 'Isi data harian di sini. Total dihitung otomatis. FCR, EEF, dan rata-rata panen belum dihitung.'} + : 'Isi data harian di sini. Total, FCR, dan EEF dihitung otomatis setelah data sumber lengkap.'}

{error && {error}} {notice && {notice}} diff --git a/docker/crontab b/docker/crontab index 504820e..986bb01 100644 --- a/docker/crontab +++ b/docker/crontab @@ -1,12 +1,15 @@ SHELL=/bin/sh PATH=/usr/local/bin:/usr/bin:/bin -# IoT panel: real-time sync every 10 minutes (not gated by 17:00 publish cutoff). +# NOTE: docker/entrypoint-cron.sh regenerates this file from DASHBOARD_PUBLISH_HOUR/MINUTE +# at container start. Keep this checked-in copy as documentation / fallback defaults (17:00). + +# IoT panel: real-time sync every 10 minutes (not gated by publish cutoff). */10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1 # Chicken counting + mortality + weight edge sync every 10 minutes (not gated by publish cutoff). */10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1 # Karung-web-admin: every 30s via karung_sync_loop.sh (started by entrypoint-cron.sh). -# Daily publish — recompute KPI rollups from latest karung + counting. +# Daily publish — recompute KPI rollups (hour/minute come from DASHBOARD_PUBLISH_* at runtime). 0 17 * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1 diff --git a/docker/entrypoint-cron.sh b/docker/entrypoint-cron.sh index d02ad0f..5151e1b 100644 --- a/docker/entrypoint-cron.sh +++ b/docker/entrypoint-cron.sh @@ -4,6 +4,27 @@ cd /app python manage.py migrate --noinput printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \ > /etc/environment || true -# Cron min resolution is 1 minute; karung polls every 30s in a side loop. + +# Render publish-time cron from DASHBOARD_PUBLISH_* (default 17:00). +PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}" +PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}" +CRON_FILE=/etc/cron.d/newdashboard +{ + echo "SHELL=/bin/sh" + echo "PATH=/usr/local/bin:/usr/bin:/bin" + echo "" + echo "# IoT panel: real-time sync every 10 minutes (not gated by publish cutoff)." + echo "*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1" + echo "" + echo "# Chicken counting + mortality + weight edge sync every 10 minutes." + echo "*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1" + echo "" + echo "# Karung-web-admin: every 30s via karung_sync_loop.sh (started below)." + echo "# Daily publish — karung sync then KPI rollups at configured cutoff." + echo "${PUBLISH_MINUTE} ${PUBLISH_HOUR} * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1" +} > "$CRON_FILE" +chmod 0644 "$CRON_FILE" +crontab "$CRON_FILE" + /app/karung_sync_loop.sh & exec cron -f