314 lines
12 KiB
Python
314 lines
12 KiB
Python
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"])
|