fix karung view data and sync only sync with the current date

This commit is contained in:
Alberto-Audrix committed 2026-09-04 09:33:17 +07:00
1 parent b3556c6ef4
commit 5ae477a99d
8 files changed
+396 -32

No files matched your search

+2 -1
View File
@@ -7,4 +7,5 @@ coverage
.vite
backup
__pycache__
docker-compose.override.yml
docker-compose.override.yml
docs/
@@ -2,11 +2,11 @@ from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.karung_web import KarungWebError, sync_karung_for_cycle
class Command(BaseCommand):
help = "Sync FeedSacks rows from karung-web-admin active sections"
help = "Sync FeedSacks rows from karung-web-admin (today sections + combined history)"
def handle(self, *args, **options):
if not settings.KARUNG_WEB_ADMIN_SYNC_ENABLED:
@@ -15,11 +15,12 @@ class Command(BaseCommand):
synced = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
try:
feed_sack, summary = request_karung(cycle, section="today")
feed_sack, summary = sync_karung_for_cycle(cycle, section="today")
synced += 1
self.stdout.write(
f"cycle={cycle.pk} in_today={feed_sack.in_today} "
f"out_today={feed_sack.out_today} date={summary.get('snapshot_date')}"
f"out_today={feed_sack.out_today} date={summary.get('snapshot_date')} "
f"history={','.join(summary.get('history_dates') or [])}"
)
except KarungWebError as exc:
self.stderr.write(f"cycle={cycle.pk} error={exc}")
+130 -1
View File
@@ -8,6 +8,7 @@ from urllib.parse import urljoin
import httpx
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.farms.models import Cycle
from apps.operations.models import FeedSacks
@@ -51,6 +52,117 @@ def _normalize_source(value: str | None) -> str:
return (value or "").strip().casefold()
def _parse_history_date(raw: Any) -> date | None:
if raw is None:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
def daily_totals_from_combined_history(
combined: dict[str, Any],
*,
kandang_name: str,
) -> dict[date, dict[str, int]]:
"""Group /api/combined history into per-date in/out/use for one kandang."""
in_source = _normalize_source(source_for_kandang(kandang_name, kind="In"))
out_source = _normalize_source(source_for_kandang(kandang_name, kind="Out"))
use_source = _normalize_source(source_for_kandang(kandang_name, kind="Use"))
by_date: dict[date, dict[str, int]] = {}
def ensure(day: date) -> dict[str, int]:
return by_date.setdefault(
day, {"in_today": 0, "out_today": 0, "feed_use_today": 0}
)
masuk_history = (combined.get("masuk") or {}).get("history") or []
for row in masuk_history:
day = _parse_history_date(row.get("date"))
if day is None:
continue
source = _normalize_source(row.get("source"))
value = int(row.get("counter_value") or row.get("karung") or 0)
if source == in_source:
ensure(day)["in_today"] = value
elif source == out_source:
ensure(day)["out_today"] = value
tuang_history = (combined.get("tuang") or {}).get("history") or []
for row in tuang_history:
day = _parse_history_date(row.get("date"))
if day is None:
continue
source = _normalize_source(row.get("source"))
value = int(row.get("counter_value") or row.get("karung") or 0)
if source == use_source:
ensure(day)["feed_use_today"] = value
return by_date
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
"""Inclusive date window for history upserts."""
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
return cycle.start_date, end
def backfill_karung_from_combined(
cycle: Cycle,
combined: dict[str, Any] | None = None,
) -> list[date]:
"""Upsert FeedSacks for cycle dates present in combined history. Returns upserted dates."""
if combined is None:
combined = KarungWebClient().fetch_combined()
kandang_name = cycle.kandang.kandang_name
by_date = daily_totals_from_combined_history(combined, kandang_name=kandang_name)
start, end = cycle_history_window(cycle)
upserted: list[date] = []
for day in sorted(by_date):
if day < start or day > end:
continue
totals = by_date[day]
FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": totals["in_today"],
"out_today": totals["out_today"],
"feed_use_today": totals["feed_use_today"],
},
)
upserted.append(day)
return upserted
def recompute_feed_sack_totals(cycle: Cycle) -> None:
"""Recompute running in/out/use totals for all FeedSacks of a cycle in date order."""
running_in = 0
running_out = 0
running_use = 0
rows = list(FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"))
for row in rows:
running_in += row.in_today or 0
running_out += row.out_today or 0
running_use += row.feed_use_today or 0
changed = (
row.in_total != running_in
or row.out_total != running_out
or row.feed_use_total != running_use
)
if changed:
row.in_total = running_in
row.out_total = running_out
row.feed_use_total = running_use
row.save(update_fields=["in_total", "out_total", "feed_use_total", "updated_at"])
class KarungWebClient:
"""Outbound client for karung-web-admin — base URL from settings only."""
@@ -155,7 +267,7 @@ def request_karung(
saldo_awal = int(remote_cycle.get("saldo_awal") or 0)
snapshot_raw = block.get("date")
snapshot_date = date.fromisoformat(str(snapshot_raw)) if snapshot_raw else date.today()
snapshot_date = date.fromisoformat(str(snapshot_raw)) if snapshot_raw else timezone.localdate()
prior = (
FeedSacks.objects.filter(cycle=cycle)
@@ -203,3 +315,20 @@ def request_karung(
"remote_cycle": remote_cycle,
}
return feed_sack, summary
def sync_karung_for_cycle(
cycle: Cycle,
*,
section: str = "today",
) -> tuple[FeedSacks, dict[str, Any]]:
"""Sync today from sections, backfill cycle history from /api/combined, recompute totals."""
feed_sack, summary = request_karung(cycle, section=section)
history_dates = backfill_karung_from_combined(cycle)
recompute_feed_sack_totals(cycle)
feed_sack.refresh_from_db()
summary = {
**summary,
"history_dates": [d.isoformat() for d in history_dates],
}
return feed_sack, summary
+145
View File
@@ -6,13 +6,18 @@ 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 = {
@@ -50,6 +55,29 @@ COMBINED_SAMPLE = {
}
}
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):
@@ -166,3 +194,120 @@ class RequestKarungTests(TestCase):
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"])
+2 -2
View File
@@ -27,7 +27,7 @@ from apps.operations.serializers import (
KPISerializer,
ManualInputSerializer,
)
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.karung_web import KarungWebError, sync_karung_for_cycle
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"})
@@ -129,7 +129,7 @@ class FeedSacksViewSet(CycleScopedViewSet):
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
section = request.data.get("section") or "today"
try:
feed_sack, summary = request_karung(cycle, section=section)
feed_sack, summary = sync_karung_for_cycle(cycle, section=section)
except (KarungWebError, ValidationError) as exc:
code = getattr(exc, "status_code", None)
return Response(
+3 -8
View File
@@ -59,15 +59,10 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
[manualRows]
);
const visibleThrough = selectedCycle?.visible_through_date;
const displayDateIso = latest?.date ?? visibleThrough ?? todayIso();
const latestManual =
sortedManualRows.find((row) => row.date === displayDateIso) ??
sortedManualRows[sortedManualRows.length - 1] ??
null;
// Display date follows the latest snapshot; without any snapshot, fall back to today.
// Cycle day is capped so data never extends past the cycle's planned duration.
// Display date follows the latest IoT snapshot; without any snapshot, fall back to today.
// Manual cards only use a same-date row — never fall back to an older day.
const displayDate = latest?.date ?? visibleThrough ?? todayIso();
const latestManual = sortedManualRows.find((row) => row.date === displayDate) ?? null;
const currentDay = useMemo(() => {
if (!selectedCycle) return 0;
const rawDay = cycleDayForDate(selectedCycle.start_date, displayDate);
@@ -1,8 +1,10 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { api } from '../../services/apiClient.ts';
import FeedSackCountingPage from '../FeedSackCountingPage.tsx';
const { karungRows, manualRows, cycle } = vi.hoisted(() => {
const { karungRows, manualRows, cycle, mismatchedKarungRows, mismatchedManualRows } = vi.hoisted(
() => {
const cycle = {
id: 1,
kandang: 1,
@@ -14,6 +16,7 @@ const { karungRows, manualRows, cycle } = vi.hoisted(() => {
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
feed_initial_balance: 0,
created_at: '',
updated_at: '',
};
@@ -105,7 +108,69 @@ const { karungRows, manualRows, cycle } = vi.hoisted(() => {
updated_at: '',
},
];
return { karungRows, manualRows, cycle };
// IoT already on the newer day; manual only exists for the previous day.
const mismatchedKarungRows = [
{
id: 10,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-02',
in_today: 192,
out_today: 1,
in_total: 192,
out_total: 1,
feed_use_today: 7,
feed_use_total: 7,
created_at: '',
updated_at: '',
},
{
id: 11,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-03',
in_today: 50,
out_today: 0,
in_total: 242,
out_total: 1,
feed_use_today: 4,
feed_use_total: 11,
created_at: '',
updated_at: '',
},
];
const mismatchedManualRows = [
{
id: 10,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-02',
age_manual: 0,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 192,
feed_use_manual: 7,
feed_in_manual_total: 192,
feed_use_manual_total: 7,
feed_out_manual: 1,
feed_out_manual_total: 1,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
return { karungRows, manualRows, cycle, mismatchedKarungRows, mismatchedManualRows };
});
vi.mock('../../services/apiClient.ts', () => ({
@@ -137,6 +202,7 @@ vi.mock('../../context/FarmContext.tsx', () => ({
selectedCycle: cycle,
cycles: [cycle],
setSelectedCycleId: vi.fn(),
updateCycle: vi.fn(),
}),
}));
@@ -147,6 +213,12 @@ class ResizeObserverStub {
}
describe('FeedSackCountingPage', () => {
beforeEach(() => {
vi.mocked(api.karungs.list).mockResolvedValue(karungRows);
vi.mocked(api.manualInputs.list).mockResolvedValue(manualRows);
vi.mocked(api.insights.list).mockResolvedValue([]);
});
it('renders the recreated hitung karung view with stats, saldo, and daily table', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
try {
@@ -192,4 +264,25 @@ describe('FeedSackCountingPage', () => {
vi.unstubAllGlobals();
}
});
it('does not reuse yesterday manual values on the latest IoT day stats cards', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
vi.mocked(api.karungs.list).mockResolvedValue(mismatchedKarungRows);
vi.mocked(api.manualInputs.list).mockResolvedValue(mismatchedManualRows);
try {
render(<FeedSackCountingPage onSettings={() => undefined} />);
expect(await screen.findByRole('heading', { name: 'Hitung Karung' })).toBeInTheDocument();
// Latest IoT day still shows.
expect(screen.getAllByText('50').length).toBeGreaterThan(0);
// Summary Manual cards show 0 when the latest IoT day has no matching manual row.
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(6);
// Daily table still keeps the real 2 Sep manual row across masuk/tuang/keluar boxes.
expect(screen.getAllByText('192').length).toBeGreaterThan(0);
expect(screen.getAllByText('3 Sep 2026').length).toBeGreaterThan(0);
expect(screen.getAllByText('2 Sep 2026').length).toBeGreaterThan(0);
} finally {
vi.unstubAllGlobals();
}
});
});
+13 -13
View File
@@ -20,7 +20,7 @@ const StatItem: React.FC<{
className="text-xl sm:text-2xl font-bold leading-none"
style={{ color: colorOverride ?? '#374151' }}
>
{value == null ? 'N/A' : formatNumber(value)}
{value == null ? formatNumber(0) : formatNumber(value)}
</p>
<p className="text-xs text-gray-400 mt-0.5">karung</p>
</div>
@@ -68,30 +68,30 @@ const FeedStats: React.FC<FeedStatsProps> = ({
title: 'Karung Masuk',
color: '#2563eb',
icon: <i className="fa-solid fa-arrow-down text-blue-600"></i>,
iotToday: latest?.in_today ?? null,
iotTotal: latest?.in_total ?? null,
manualToday: latestManual?.feed_in_manual ?? null,
manualTotal: latestManual?.feed_in_manual_total ?? null,
iotToday: latest?.in_today ?? 0,
iotTotal: latest?.in_total ?? 0,
manualToday: latestManual?.feed_in_manual ?? 0,
manualTotal: latestManual?.feed_in_manual_total ?? 0,
},
{
key: 'dituang',
title: 'Karung Dituang',
color: '#ea580c',
icon: <i className="fa-solid fa-arrow-up text-orange-600"></i>,
iotToday: latest?.feed_use_today ?? null,
iotTotal: latest?.feed_use_total ?? null,
manualToday: latestManual?.feed_use_manual ?? null,
manualTotal: latestManual?.feed_use_manual_total ?? null,
iotToday: latest?.feed_use_today ?? 0,
iotTotal: latest?.feed_use_total ?? 0,
manualToday: latestManual?.feed_use_manual ?? 0,
manualTotal: latestManual?.feed_use_manual_total ?? 0,
},
{
key: 'keluar',
title: 'Karung Keluar',
color: '#dc2626',
icon: <i className="fa-solid fa-arrow-right text-red-600"></i>,
iotToday: latest?.out_today ?? null,
iotTotal: latest?.out_total ?? null,
manualToday: latestManual?.feed_out_manual ?? null,
manualTotal: latestManual?.feed_out_manual_total ?? null,
iotToday: latest?.out_today ?? 0,
iotTotal: latest?.out_total ?? 0,
manualToday: latestManual?.feed_out_manual ?? 0,
manualTotal: latestManual?.feed_out_manual_total ?? 0,
},
],
[latest, latestManual]