first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,214 @@
from __future__ import annotations
import re
from datetime import date
from typing import Any
from urllib.parse import urljoin
import httpx
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting
class ChickenCountingEdgeError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def kandang_index(kandang_name: str) -> str:
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive edge kandang index from kandang name: {kandang_name!r}"
)
return match.group(1)
def location_prefix_for_kandang(kandang_name: str) -> str:
"""``Kandang 1`` → ``K1`` (matches floor prefixes like ``K1-L1``)."""
return f"K{kandang_index(kandang_name)}"
def coop_for_kandang(kandang_name: str) -> str:
"""``Kandang 1`` → ``K1`` (mortality ``coop`` field)."""
return location_prefix_for_kandang(kandang_name)
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 aggregate_counting_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, int]:
"""Sum ``total`` for floors whose location starts with ``KN-`` / equals ``KN``."""
prefix = location_prefix_for_kandang(kandang_name)
by_date: dict[date, int] = {}
for row in rows or []:
day = _parse_history_date(row.get("date"))
if day is None:
continue
location = str(row.get("location") or "").strip()
if location != prefix and not location.startswith(f"{prefix}-"):
continue
by_date[day] = by_date.get(day, 0) + int(row.get("total") or 0)
return by_date
def aggregate_mortality_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, int]:
"""Map date → ``total_mortality_count`` for the matching coop."""
expected = coop_for_kandang(kandang_name).casefold()
by_date: dict[date, int] = {}
for row in rows or []:
day = _parse_history_date(row.get("date"))
if day is None:
continue
coop = str(row.get("coop") or row.get("location") or "").strip()
if coop.casefold() != expected:
continue
by_date[day] = int(row.get("total_mortality_count") or 0)
return by_date
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
return cycle.start_date, end
class ChickenCountingEdgeClient:
"""Outbound client for the chicken-counting edge box."""
DB_HISTORY_PATH = "/api/db/history"
MORTALITY_HISTORY_PATH = "/api/mortality/history"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (
base_url or getattr(settings, "CHICKEN_COUNTING_EDGE_BASE_URL", "") or ""
).rstrip("/")
self.timeout = timeout or float(
getattr(settings, "CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS", 30) or 30
)
def _get_json(self, path: str) -> Any:
if not self.base_url:
raise ChickenCountingEdgeError("CHICKEN_COUNTING_EDGE_BASE_URL is not configured")
url = urljoin(self.base_url + "/", path.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise ChickenCountingEdgeError(f"chicken counting edge request failed: {exc}") from exc
if response.status_code >= 400:
raise ChickenCountingEdgeError(
f"chicken counting edge HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
def fetch_db_history(self) -> list[dict[str, Any]]:
payload = self._get_json(self.DB_HISTORY_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("results") or payload.get("history") or [])
def fetch_mortality_history(self) -> list[dict[str, Any]]:
payload = self._get_json(self.MORTALITY_HISTORY_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("results") or payload.get("history") or [])
@transaction.atomic
def sync_chicken_counting_for_cycle(
cycle: Cycle,
*,
db_history: list[dict[str, Any]] | None = None,
mortality_history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Upsert ChickenCounting from edge history within the cycle window."""
counting_enabled = bool(
getattr(settings, "CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED", True)
)
mortality_enabled = bool(
getattr(settings, "CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", True)
)
if not counting_enabled and not mortality_enabled:
return {"dates": [], "upserted": 0, "skipped": True}
client = ChickenCountingEdgeClient()
if db_history is None and counting_enabled:
db_history = client.fetch_db_history()
if mortality_history is None and mortality_enabled:
mortality_history = client.fetch_mortality_history()
kandang_name = cycle.kandang.kandang_name
counting_by_date = (
aggregate_counting_by_date(db_history, kandang_name=kandang_name)
if counting_enabled
else {}
)
mortality_by_date = (
aggregate_mortality_by_date(mortality_history, kandang_name=kandang_name)
if mortality_enabled
else {}
)
start, end = cycle_history_window(cycle)
dates = sorted(set(counting_by_date) | set(mortality_by_date))
upserted: list[date] = []
for day in dates:
if day < start or day > end:
continue
defaults: dict[str, int] = {}
if counting_enabled and day in counting_by_date:
defaults["total_count"] = counting_by_date[day]
if mortality_enabled and day in mortality_by_date:
defaults["mortality_count"] = mortality_by_date[day]
if not defaults:
continue
obj, created = ChickenCounting.objects.get_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": defaults.get("total_count", 0),
"mortality_count": defaults.get("mortality_count", 0),
},
)
if not created:
update_fields = []
if "total_count" in defaults and obj.total_count != defaults["total_count"]:
obj.total_count = defaults["total_count"]
update_fields.append("total_count")
if (
"mortality_count" in defaults
and obj.mortality_count != defaults["mortality_count"]
):
obj.mortality_count = defaults["mortality_count"]
update_fields.append("mortality_count")
if update_fields:
update_fields.append("updated_at")
obj.save(update_fields=update_fields)
upserted.append(day)
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import date
from apps.farms.models import Cycle
from apps.operations.models import FeedSacks
def recompute_feed_sack_in_totals(cycle: Cycle) -> None:
"""Rebuild cumulative in_total from daily IoT feed-in only (excludes opening balance)."""
running = 0
for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"):
running += fs_row.in_today
if fs_row.in_total != running:
fs_row.in_total = running
fs_row.save(update_fields=["in_total", "updated_at"])
def remove_opening_balance_feed_row(
cycle: Cycle,
*,
balance_date: date | None,
balance_amount: int,
) -> None:
"""Drop a FeedSacks row that only existed to carry opening balance."""
if balance_date is None or balance_amount <= 0:
return
FeedSacks.objects.filter(
cycle=cycle,
date=balance_date,
in_today=balance_amount,
out_today=0,
feed_use_today=0,
out_total=0,
feed_use_total=0,
).delete()
def cleanup_opening_balance_feed_rows(
cycle: Cycle,
*,
previous_date: date | None,
previous_balance: int,
balance_date: date,
balance_amount: int,
) -> None:
"""Remove opening-balance snapshot rows for the previous and current save."""
for row_date, amount in (
(previous_date, previous_balance),
(balance_date, balance_amount),
):
remove_opening_balance_feed_row(
cycle,
balance_date=row_date,
balance_amount=amount,
)
# Legacy cleanup: drop opening-only rows left from earlier saves on other dates.
opening_amounts = {
amount for amount in (previous_balance, balance_amount) if amount > 0
}
if not opening_amounts:
return
FeedSacks.objects.filter(
cycle=cycle,
in_today__in=opening_amounts,
out_today=0,
feed_use_today=0,
out_total=0,
feed_use_total=0,
).delete()
+264
View File
@@ -0,0 +1,264 @@
"""Sync IoT panel readings from dashboard.cpsp.id/api/iot/flocks/ (10-minute snapshots)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone as dt_timezone
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from django.conf import settings
from django.utils import timezone
from apps.farms.models import Cycle, Flock
from apps.operations.models import IotPanel
WIB = ZoneInfo("Asia/Jakarta")
CHILL_FACTOR_POINTS = (
(0, 8.0),
(1, 8.0),
(7, 7.0),
(14, 6.0),
(21, 4.5),
(28, 3.5),
(35, 3.5),
(42, 3.0),
)
class IotApiError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _sensor_celsius(sensor: dict | float | int | None) -> float | None:
if sensor is None:
return None
if isinstance(sensor, (int, float)):
return float(sensor)
raw = sensor.get("value")
if raw is None:
return None
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
def _sensor_humidity_pct(sensor: dict | float | int | None, fallback: float | None) -> float:
if isinstance(sensor, dict):
raw = sensor.get("value")
if raw is not None:
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
if isinstance(sensor, (int, float)):
return float(sensor)
if fallback is None:
return 70.0
value = float(fallback)
return value / 10.0 if value > 100 else value
def _wind_ms(payload_wind: float | int | None, sensors_wind: dict | float | int | None) -> float:
if isinstance(sensors_wind, dict) and sensors_wind.get("value") is not None:
return float(sensors_wind["value"]) / 10.0
if isinstance(sensors_wind, (int, float)):
return float(sensors_wind)
if payload_wind is None:
return 0.0
return float(payload_wind) / 10.0
def _average_inside_temp_c(sensors: dict | None, actual_temperature: float | int | None) -> float:
if sensors:
readings = [
v
for v in (
_sensor_celsius(sensors.get("temperature1")),
_sensor_celsius(sensors.get("temperature2")),
_sensor_celsius(sensors.get("temperature3")),
)
if v is not None
]
if readings:
return sum(readings) / len(readings)
if actual_temperature is None:
return 0.0
value = float(actual_temperature)
return value / 10.0 if value > 100 else value
def _chicken_chill_factor(age: float) -> float:
if age <= CHILL_FACTOR_POINTS[0][0]:
return CHILL_FACTOR_POINTS[0][1]
for idx in range(1, len(CHILL_FACTOR_POINTS)):
prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1]
next_age, next_factor = CHILL_FACTOR_POINTS[idx]
if age > next_age:
continue
span = next_age - prev_age
if span <= 0:
return next_factor
progress = (age - prev_age) / span
return prev_factor + (next_factor - prev_factor) * progress
return CHILL_FACTOR_POINTS[-1][1]
def _experience_temperature(
*,
average_temperature: float,
wind_speed: float,
humidity_pct: float,
age_days: float,
) -> float:
chill_factor = _chicken_chill_factor(age_days)
rh_adjustment = (humidity_pct - 70.0) / 5.0
chill_effect = wind_speed * chill_factor - rh_adjustment
return average_temperature - chill_effect
def panel_fields_from_payload(data: dict, age_days: int) -> dict[str, float]:
sensors = data.get("sensors") or {}
humidity = _sensor_humidity_pct(sensors.get("humidity"), data.get("humidity"))
wind_speed = _wind_ms(data.get("wind"), sensors.get("wind"))
avg_temp = _average_inside_temp_c(sensors, data.get("actualTemperature"))
water = data.get("water")
if water is None and isinstance(sensors.get("water"), dict):
water = sensors["water"].get("value")
return {
"wind_speed": round(wind_speed, 4),
"humidity": round(humidity, 4),
"water_total": float(water or 0),
"average_temperature": round(avg_temp, 4),
"experience_temperature": round(
_experience_temperature(
average_temperature=avg_temp,
wind_speed=wind_speed,
humidity_pct=humidity,
age_days=age_days,
),
4,
),
}
def parse_fetched_at(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if timezone.is_naive(parsed):
return timezone.make_aware(parsed, dt_timezone.utc)
return parsed
def cycle_age_for_date(cycle_start: date, snapshot_date: date) -> int:
if snapshot_date < cycle_start:
return 1
return (snapshot_date - cycle_start).days + 1
@dataclass
class SyncSummary:
upserted: int
skipped: int
pages: int
class IotApiClient:
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.IOT_API_BASE_URL).rstrip("/") + "/"
self.timeout = timeout or settings.IOT_API_SYNC_TIMEOUT_SECONDS
def fetch_page(self, page: int) -> dict[str, Any]:
url = f"{self.base_url}?page={page}"
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise IotApiError(f"IoT API request failed: {exc}") from exc
if response.status_code >= 400:
raise IotApiError(
f"IoT API HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
def sync_iot_panels(
*,
lookback_minutes: int | None = None,
max_pages: int | None = None,
) -> SyncSummary:
flock_map: dict[str, int] = getattr(settings, "IOT_FLOCK_ID_MAP", {}) or {}
if not flock_map:
return SyncSummary(upserted=0, skipped=0, pages=0)
lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES
page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES
cutoff = timezone.now() - timedelta(minutes=lookback)
django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items():
try:
django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id))
except Flock.DoesNotExist:
continue
client = IotApiClient()
upserted = 0
skipped = 0
pages_read = 0
for page in range(1, page_limit + 1):
payload = client.fetch_page(page)
pages_read += 1
results = payload.get("results") or []
if not results:
break
page_has_recent = False
for record in results:
external_flock_id = str(record.get("flock_id") or "")
flock = django_flocks.get(external_flock_id)
if flock is None:
skipped += 1
continue
fetched_raw = record.get("fetched_at")
payload_data = (record.get("payload") or {}).get("data")
if not fetched_raw or not payload_data:
skipped += 1
continue
fetched_at = parse_fetched_at(fetched_raw)
if fetched_at >= cutoff:
page_has_recent = True
elif page > 1:
continue
snapshot_date = fetched_at.astimezone(WIB).date()
cycle = (
Cycle.objects.filter(
kandang_id=flock.kandang_id,
start_date__lte=snapshot_date,
end_date__gte=snapshot_date,
)
.order_by("-start_date")
.first()
)
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create(
flock=flock,
timestamp=fetched_at,
defaults={
"date": snapshot_date,
**fields,
},
)
upserted += 1
if not page_has_recent and page > 1:
break
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
@@ -0,0 +1,334 @@
from __future__ import annotations
import re
from datetime import date
from typing import Any
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
class KarungWebError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def kandang_index(kandang_name: str) -> str:
"""Extract the numeric kandang index from labels like ``Kandang 1``."""
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive IoT kandang index from kandang name: {kandang_name!r}"
)
return match.group(1)
def source_for_kandang(kandang_name: str, *, kind: str) -> str:
"""Map a kandang label to a karung-web-admin source such as ``K1 - In``."""
return f"K{kandang_index(kandang_name)} - {kind}"
def masuk_source_for_kandang(kandang_name: str) -> str:
"""Map a kandang label like ``Kandang 1`` to the karung-web-admin source ``K1 - In``."""
return source_for_kandang(kandang_name, kind="In")
def _karung_for_source(rows: list[dict[str, Any]] | None, expected_source: str) -> int:
normalized = _normalize_source(expected_source)
for row in rows or []:
if _normalize_source(row.get("source")) == normalized:
return int(row.get("karung") or row.get("counter_value") or 0)
return 0
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."""
SECTIONS_PATH = "/api/v1/cycles/active/sections"
COMBINED_PATH = "/api/combined"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.KARUNG_WEB_ADMIN_BASE_URL).rstrip("/")
self.timeout = timeout or settings.KARUNG_WEB_ADMIN_TIMEOUT_SECONDS
def _get_json(self, path: str) -> dict[str, Any]:
if not self.base_url:
raise KarungWebError("KARUNG_WEB_ADMIN_BASE_URL is not configured")
url = urljoin(self.base_url + "/", path.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise KarungWebError(f"karung-web-admin request failed: {exc}") from exc
if response.status_code >= 400:
raise KarungWebError(
f"karung-web-admin HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
payload = response.json()
if not payload.get("success", True) and payload.get("error"):
error = payload.get("error") or {}
raise KarungWebError(error.get("message") or "karung-web-admin returned failure")
return payload.get("data") or payload
def fetch_active_sections(self) -> dict[str, Any]:
return self._get_json(self.SECTIONS_PATH)
def fetch_combined(self) -> dict[str, Any]:
return self._get_json(self.COMBINED_PATH)
def fetch_iot_masuk_for_date(target_date: date, *, kandang_name: str) -> int | None:
"""Return IoT karung-masuk for ``target_date`` and kandang from karung-web-admin /api/combined."""
expected_source = _normalize_source(masuk_source_for_kandang(kandang_name))
target = target_date.isoformat()
client = KarungWebClient()
data = client.fetch_combined()
masuk = data.get("masuk") or {}
for row in masuk.get("history") or []:
if row.get("date") == target and _normalize_source(row.get("source")) == expected_source:
return int(row.get("counter_value") or 0)
if target_date == date.today():
for row in masuk.get("today") or []:
if _normalize_source(row.get("source")) == expected_source:
return int(row.get("karung") or 0)
return None
def initial_balance_accuracy(manual: int, iot_in: int | None) -> float | None:
if manual > 0 and iot_in is not None and iot_in > 0:
return round(min(manual, iot_in) / max(manual, iot_in) * 1000) / 10
if manual == 0 and iot_in == 0:
return 100.0
return None
def pull_initial_balance_iot(cycle: Cycle, balance_date: date) -> tuple[int | None, str | None]:
sync_error = None
iot_in = None
try:
iot_in = fetch_iot_masuk_for_date(
balance_date,
kandang_name=cycle.kandang.kandang_name,
)
except KarungWebError as exc:
sync_error = str(exc)
return iot_in, sync_error
def request_karung(
cycle: Cycle,
*,
section: str = "today",
) -> tuple[FeedSacks, dict[str, Any]]:
if section not in {"today", "yesterday_history"}:
raise ValidationError("section must be today or yesterday_history")
client = KarungWebClient()
data = client.fetch_active_sections()
sections = data.get("sections") or {}
block = sections.get(section) or {}
totals = block.get("totals") or {}
remote_cycle = data.get("cycle") or {}
kandang_name = cycle.kandang.kandang_name
k_index = kandang_index(kandang_name)
masuk_rows = block.get("masuk") or []
tuang_rows = block.get("tuang") or []
in_today = _karung_for_source(masuk_rows, f"K{k_index} - In")
feed_use_today = _karung_for_source(tuang_rows, f"K{k_index} - Use")
out_today = _karung_for_source(masuk_rows, f"K{k_index} - Out")
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 timezone.localdate()
prior = (
FeedSacks.objects.filter(cycle=cycle)
.exclude(date=snapshot_date)
.order_by("-date", "-pk")
.first()
)
if prior:
in_total = prior.in_total + in_today
out_total = prior.out_total + out_today
feed_use_total = prior.feed_use_total + feed_use_today
else:
in_total = in_today
out_total = out_today
feed_use_total = feed_use_today
feed_sack, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=snapshot_date,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": in_total,
"out_total": out_total,
"feed_use_today": feed_use_today,
"feed_use_total": feed_use_total,
},
)
summary = {
"section": section,
"snapshot_date": snapshot_date.isoformat(),
"totals": totals,
"kandang": kandang_name,
"kandang_sources": {
"in": source_for_kandang(kandang_name, kind="In"),
"use": source_for_kandang(kandang_name, kind="Use"),
"out": source_for_kandang(kandang_name, kind="Out"),
},
"kandang_totals": {
"in_today": in_today,
"feed_use_today": feed_use_today,
"out_today": out_today,
},
"saldo_awal": saldo_awal,
"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
@@ -0,0 +1,38 @@
from __future__ import annotations
from apps.farms.models import Cycle
from apps.operations.models import ManualInput
DAILY_TO_TOTAL = (
("mortality_manual", "mortality_manual_total"),
("feed_in_manual", "feed_in_manual_total"),
("feed_use_manual", "feed_use_manual_total"),
("feed_out_manual", "feed_out_manual_total"),
("harvest_manual", "harvest_manual_total"),
("harvest_weight_manual", "harvest_weight_manual_total"),
)
SKIPPED_DERIVED_FIELDS = (
"average_harvest_day_manual",
"average_harvest_weight_manual",
"fcr_manual",
"eef_manual",
)
def recompute_manual_totals(cycle: Cycle) -> None:
running = {total: 0 for _, total in DAILY_TO_TOTAL}
rows = list(ManualInput.objects.filter(cycle=cycle).order_by("date", "pk"))
for row in rows:
changed: list[str] = []
for daily, total in DAILY_TO_TOTAL:
running[total] += getattr(row, daily) or 0
if getattr(row, total) != running[total]:
setattr(row, total, running[total])
changed.append(total)
for field in SKIPPED_DERIVED_FIELDS:
if getattr(row, field) != 0:
setattr(row, field, 0)
changed.append(field)
if changed:
row.save(update_fields=[*changed, "updated_at"])
@@ -0,0 +1,29 @@
"""Dashboard publish cutoff — day D data visible from 17:00 WIB on day D."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from django.conf import settings
from django.utils import timezone
def dashboard_publish_time() -> time:
hour = int(getattr(settings, "DASHBOARD_PUBLISH_HOUR", 17))
minute = int(getattr(settings, "DASHBOARD_PUBLISH_MINUTE", 0))
return time(hour, minute)
def visible_through_date(now: datetime | None = None) -> date:
"""Latest calendar date (WIB) allowed on dashboard reads."""
if now is None:
now = timezone.localtime()
elif timezone.is_naive(now):
now = timezone.make_aware(now, timezone.get_current_timezone())
else:
now = timezone.localtime(now)
today = now.date()
if now.time() < dashboard_publish_time():
return today - timedelta(days=1)
return today