fix karung view data and sync only sync with the current date
This commit is contained in:
1 parent
b3556c6ef4
commit
5ae477a99d
8 files changed
+396
-32
No files matched your search
+2
-1
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user