from datetime import date from unittest.mock import patch from django.core.exceptions import ValidationError 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 FeedSacks from apps.operations.services.karung_web import ( KarungWebError, backfill_karung_from_combined, daily_totals_from_combined_history, fetch_iot_masuk_for_date, initial_balance_accuracy, masuk_source_for_kandang, recompute_feed_sack_totals, request_karung, source_for_kandang, sync_karung_for_cycle, ) ACTIVE_SECTIONS_SAMPLE = { "sections": { "today": { "date": "2026-09-02", "masuk": [ {"karung": 192, "source": "K1 - In"}, {"karung": 2, "source": "K1 - Out"}, {"karung": 120, "source": "K2 - In"}, {"karung": 5, "source": "K2 - Out"}, ], "tuang": [ {"karung": 7, "source": "K1 - Use"}, {"karung": 9, "source": "K2 - Use"}, ], "totals": {"masuk": 312, "tuang": 16}, } }, "cycle": {"saldo_awal": 240}, } COMBINED_SAMPLE = { "masuk": { "history": [ {"counter_value": 160, "date": "2026-09-02", "id": 17, "source": "K1 - In"}, {"counter_value": 80, "date": "2026-09-02", "id": 19, "source": "K2 - In"}, {"counter_value": 112, "date": "2026-08-27", "id": 9, "source": "K1 - In"}, {"counter_value": 32, "date": "2026-08-27", "id": 11, "source": "K2 - In"}, ], "today": [ {"karung": 160, "source": "K1 - In"}, {"karung": 80, "source": "K2 - In"}, ], } } COMBINED_HISTORY_FULL = { "masuk": { "history": [ {"counter_value": 192, "date": "2026-09-02", "source": "K1 - In"}, {"counter_value": 1, "date": "2026-09-02", "source": "K1 - Out"}, {"counter_value": 50, "date": "2026-09-03", "source": "K1 - In"}, {"counter_value": 0, "date": "2026-09-03", "source": "K1 - Out"}, {"counter_value": 120, "date": "2026-09-02", "source": "K2 - In"}, {"counter_value": 2, "date": "2026-09-02", "source": "K2 - Out"}, {"counter_value": 112, "date": "2026-08-27", "source": "K1 - In"}, ], "today": [], }, "tuang": { "history": [ {"counter_value": 7, "date": "2026-09-02", "source": "K1 - Use"}, {"counter_value": 4, "date": "2026-09-03", "source": "K1 - Use"}, {"counter_value": 9, "date": "2026-09-02", "source": "K2 - Use"}, ], "today": [], }, } class MasukSourceForKandangTests(TestCase): def test_maps_kandang_number_to_iot_source(self): self.assertEqual(masuk_source_for_kandang("Kandang 1"), "K1 - In") self.assertEqual(masuk_source_for_kandang("Kandang 2"), "K2 - In") self.assertEqual(source_for_kandang("Kandang 2", kind="Use"), "K2 - Use") self.assertEqual(source_for_kandang("Kandang 2", kind="Out"), "K2 - Out") def test_rejects_kandang_name_without_number(self): with self.assertRaises(ValidationError): masuk_source_for_kandang("Utara") class InitialBalanceAccuracyTests(TestCase): def test_returns_ratio_when_both_values_positive(self): self.assertEqual(initial_balance_accuracy(120, 115), 95.8) def test_returns_100_when_both_zero(self): self.assertEqual(initial_balance_accuracy(0, 0), 100.0) def test_returns_none_when_iot_missing(self): self.assertIsNone(initial_balance_accuracy(120, None)) @override_settings(KARUNG_WEB_ADMIN_BASE_URL="http://karung.internal") class KarungWebCombinedTests(TestCase): @patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined") def test_fetch_iot_masuk_for_date_uses_kandang_specific_history_row(self, mock_fetch): mock_fetch.return_value = COMBINED_SAMPLE result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 2") self.assertEqual(result, 32) @patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined") def test_fetch_iot_masuk_for_date_matches_source_case_insensitively(self, mock_fetch): mock_fetch.return_value = { "masuk": { "history": [ {"counter_value": 99, "date": "2026-08-27", "source": "K1 - in"}, ] } } result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1") self.assertEqual(result, 99) @patch("apps.operations.services.karung_web.date") @patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined") def test_fetch_iot_masuk_for_date_falls_back_to_today_live_rows(self, mock_fetch, mock_date): mock_date.today.return_value = date(2026, 9, 2) mock_fetch.return_value = {"masuk": {"history": [], "today": COMBINED_SAMPLE["masuk"]["today"]}} result = fetch_iot_masuk_for_date(date(2026, 9, 2), kandang_name="Kandang 1") self.assertEqual(result, 160) @patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined") def test_fetch_iot_masuk_for_date_returns_none_when_day_missing(self, mock_fetch): mock_fetch.return_value = COMBINED_SAMPLE result = fetch_iot_masuk_for_date(date(2026, 8, 26), kandang_name="Kandang 1") self.assertIsNone(result) @patch( "apps.operations.services.karung_web.KarungWebClient.fetch_combined", side_effect=KarungWebError("upstream down"), ) def test_fetch_iot_masuk_for_date_propagates_upstream_errors(self, _mock_fetch): with self.assertRaises(KarungWebError): fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1") @override_settings(KARUNG_WEB_ADMIN_BASE_URL="http://karung.internal") class RequestKarungTests(TestCase): def setUp(self): user = User.objects.create_user(user_name="tester", password="secret") site = Site.objects.create(site_name="Sukawarna", user=user) self.kandang1 = Kandang.objects.create(kandang_name="Kandang 1", site=site) self.kandang2 = Kandang.objects.create(kandang_name="Kandang 2", site=site) self.cycle1 = Cycle.objects.create( kandang=self.kandang1, total_days=35, start_date=date(2026, 9, 2), end_date=date(2026, 10, 5), doc_in_weight=40, doc_in_count=10000, status=Cycle.STATUS_ACTIVE, ) self.cycle2 = Cycle.objects.create( kandang=self.kandang2, total_days=35, start_date=date(2026, 9, 2), end_date=date(2026, 10, 5), doc_in_weight=40, doc_in_count=10000, status=Cycle.STATUS_ACTIVE, ) @patch("apps.operations.services.karung_web.KarungWebClient.fetch_active_sections") def test_request_karung_uses_per_kandang_sources_not_aggregate_totals(self, mock_fetch): mock_fetch.return_value = ACTIVE_SECTIONS_SAMPLE feed_sack1, summary1 = request_karung(self.cycle1, section="today") feed_sack2, summary2 = request_karung(self.cycle2, section="today") self.assertEqual(feed_sack1.in_today, 192) self.assertEqual(feed_sack1.feed_use_today, 7) self.assertEqual(feed_sack1.out_today, 2) self.assertEqual(feed_sack2.in_today, 120) self.assertEqual(feed_sack2.feed_use_today, 9) self.assertEqual(feed_sack2.out_today, 5) self.assertEqual(summary1["kandang_totals"]["in_today"], 192) self.assertEqual(summary2["kandang_totals"]["in_today"], 120) def test_daily_totals_from_combined_history_groups_by_date_for_kandang(self): by_date = daily_totals_from_combined_history( COMBINED_HISTORY_FULL, kandang_name="Kandang 1" ) self.assertEqual( by_date[date(2026, 9, 2)], {"in_today": 192, "out_today": 1, "feed_use_today": 7}, ) self.assertEqual( by_date[date(2026, 9, 3)], {"in_today": 50, "out_today": 0, "feed_use_today": 4}, ) self.assertEqual( by_date[date(2026, 8, 27)], {"in_today": 112, "out_today": 0, "feed_use_today": 0}, ) self.assertNotIn(date(2026, 9, 1), by_date) @patch("apps.operations.services.karung_web.timezone") def test_backfill_upserts_in_cycle_range_and_skips_outside(self, mock_tz): mock_tz.localdate.return_value = date(2026, 9, 3) dates = backfill_karung_from_combined(self.cycle1, COMBINED_HISTORY_FULL) self.assertEqual(dates, [date(2026, 9, 2), date(2026, 9, 3)]) row_sep2 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2)) self.assertEqual(row_sep2.in_today, 192) self.assertEqual(row_sep2.feed_use_today, 7) self.assertEqual(row_sep2.out_today, 1) self.assertFalse( FeedSacks.objects.filter(cycle=self.cycle1, date=date(2026, 8, 27)).exists() ) @patch("apps.operations.services.karung_web.timezone") def test_backfill_refreshes_existing_row_from_upstream(self, mock_tz): mock_tz.localdate.return_value = date(2026, 9, 3) FeedSacks.objects.create( cycle=self.cycle1, date=date(2026, 9, 2), in_today=1, out_today=0, feed_use_today=0, in_total=1, out_total=0, feed_use_total=0, ) backfill_karung_from_combined(self.cycle1, COMBINED_HISTORY_FULL) row = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2)) self.assertEqual(row.in_today, 192) self.assertEqual(row.feed_use_today, 7) self.assertEqual(row.out_today, 1) def test_recompute_feed_sack_totals_accumulates_in_date_order(self): FeedSacks.objects.create( cycle=self.cycle1, date=date(2026, 9, 2), in_today=192, out_today=1, feed_use_today=7, in_total=0, out_total=0, feed_use_total=0, ) FeedSacks.objects.create( cycle=self.cycle1, date=date(2026, 9, 3), in_today=50, out_today=0, feed_use_today=4, in_total=0, out_total=0, feed_use_total=0, ) recompute_feed_sack_totals(self.cycle1) sep2 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2)) sep3 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 3)) self.assertEqual((sep2.in_total, sep2.feed_use_total, sep2.out_total), (192, 7, 1)) self.assertEqual((sep3.in_total, sep3.feed_use_total, sep3.out_total), (242, 11, 1)) @patch("apps.operations.services.karung_web.timezone") @patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined") @patch("apps.operations.services.karung_web.KarungWebClient.fetch_active_sections") def test_sync_karung_for_cycle_runs_today_then_history_backfill( self, mock_sections, mock_combined, mock_tz ): mock_tz.localdate.return_value = date(2026, 9, 3) mock_sections.return_value = { "sections": { "today": { "date": "2026-09-03", "masuk": [ {"karung": 50, "source": "K1 - In"}, {"karung": 0, "source": "K1 - Out"}, ], "tuang": [{"karung": 4, "source": "K1 - Use"}], "totals": {"masuk": 50, "tuang": 4}, } }, "cycle": {"saldo_awal": 0}, } mock_combined.return_value = COMBINED_HISTORY_FULL feed_sack, summary = sync_karung_for_cycle(self.cycle1, section="today") self.assertEqual(feed_sack.date, date(2026, 9, 3)) self.assertEqual(feed_sack.in_today, 50) self.assertEqual(feed_sack.in_total, 242) sep2 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2)) self.assertEqual(sep2.in_today, 192) self.assertEqual(sep2.in_total, 192) self.assertEqual(summary["history_dates"], ["2026-09-02", "2026-09-03"])