update api export for executive and fixing closing cycle flow

This commit is contained in:
Alberto-Audrix committed 2026-09-15 16:53:25 +07:00
1 parent d0d1ae7d24
commit 07d36081ce
23 files changed
+1378 -101

No files matched your search

+1 -1
View File
@@ -110,7 +110,7 @@ Important production values:
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) | | `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation | | `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation |
| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) | | `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) |
| `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) | | `SITE_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
Wagtail admin is proxied at **http://localhost/admin/** (through Nginx → Django). Wagtail admin is proxied at **http://localhost/admin/** (through Nginx → Django).
+4 -4
View File
@@ -24,8 +24,8 @@ API_PAGE_SIZE=50
SESSION_COOKIE_SECURE=False SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False CSRF_COOKIE_SECURE=False
# Optional: create hashed ApiKey on seed/bootstrap (raw shown once in seed output) # Optional: fixed machine API key for pusat pull + scripts (hashed at rest).
BOOTSTRAP_API_KEY= SITE_API_KEY=
# Production bootstrap (bootstrap_admin — no demo data) # Production bootstrap (bootstrap_admin — no demo data)
BOOTSTRAP_ADMIN_USER=admin BOOTSTRAP_ADMIN_USER=admin
@@ -36,8 +36,8 @@ BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD= BOOTSTRAP_STAFF_PASSWORD=
# Pusat (executive) cycle-close + register channel # Pusat (executive) cycle-close + register channel
# Outbound register/notify uses these; inbound apply-close-decision requires # Outbound register/notify uses PUSAT_API_KEY; inbound apply-close-decision
# PUSAT_API_KEY in X-API-Key. active_site_id is persisted on register. # authenticates with SITE_API_KEY (X-API-Key). active_site_id is persisted on register.
PUSAT_BASE_URL= PUSAT_BASE_URL=
PUSAT_API_KEY= PUSAT_API_KEY=
# This site's public API root including /api/v1 (written to executive ActiveSite). # This site's public API root including /api/v1 (written to executive ActiveSite).
+394
View File
@@ -0,0 +1,394 @@
"""Pusat pull exports: ops (cycle metrics) and IoT (flock panels)."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from django.db.models import QuerySet
from django.utils import timezone
from django.utils.dateparse import parse_date
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.operations.serializers import (
AIInsightSerializer,
ChickenCountingSerializer,
ChickenWeightSerializer,
FeedSacksSerializer,
IotPanelSerializer,
KPISerializer,
ManualInputSerializer,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
DEFAULT_PAGE_SIZE = 500
MAX_PAGE_SIZE = 2000
class PusatExportError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
def _parse_page(raw_page: str | None, raw_size: str | None) -> tuple[int, int]:
try:
page = int(raw_page or "1")
page_size = int(raw_size or str(DEFAULT_PAGE_SIZE))
except (TypeError, ValueError) as exc:
raise PusatExportError("page dan page_size harus angka.") from exc
if page < 1:
raise PusatExportError("page harus >= 1.")
if page_size < 1 or page_size > MAX_PAGE_SIZE:
raise PusatExportError(f"page_size harus antara 1 dan {MAX_PAGE_SIZE}.")
return page, page_size
def _parse_optional_date(value: str | None, label: str) -> date | None:
if value is None or str(value).strip() == "":
return None
parsed = parse_date(str(value).strip())
if parsed is None:
raise PusatExportError(f"{label} tidak valid (gunakan YYYY-MM-DD).")
return parsed
def _default_window() -> tuple[date, date]:
today = timezone.localdate()
return today - timedelta(days=6), today
def _paginate(qs: QuerySet, *, page: int, page_size: int) -> dict[str, Any]:
total = qs.count()
start = (page - 1) * page_size
end = start + page_size
return {
"count": total,
"page": page,
"page_size": page_size,
"results": list(qs[start:end]),
}
def _enrich_site_id(rows: list[dict], site_id: str) -> list[dict]:
for row in rows:
row["site_id"] = site_id
return rows
def _serialize_page(
qs: QuerySet,
serializer_class,
*,
page: int,
page_size: int,
site_id: str,
) -> dict[str, Any]:
page_data = _paginate(qs, page=page, page_size=page_size)
serialized = serializer_class(page_data["results"], many=True).data
page_data["results"] = _enrich_site_id(list(serialized), site_id)
return page_data
def _site_payload(site: Site) -> dict[str, Any]:
return {
"site_id": site.pk,
"site_name": site.site_name,
"pusat_code": site.pusat_code or "",
"pusat_active_site_id": site.pusat_active_site_id,
"pusat_registered_at": site.pusat_registered_at,
"created_at": site.created_at,
"updated_at": site.updated_at,
}
def _kandang_payload(kandang: Kandang, *, include_feed_urls: bool = True) -> dict[str, Any]:
data: dict[str, Any] = {
"kandang_id": kandang.pk,
"kandang_name": kandang.kandang_name,
"created_at": kandang.created_at,
"updated_at": kandang.updated_at,
}
if include_feed_urls:
data["feed_in_button_urls"] = kandang.feed_in_button_urls or []
return data
def resolve_ops_export_params(
*,
site_id: str | None,
cycle_id: str | None,
date_from: str | None,
date_to: str | None,
page: str | None,
page_size: str | None,
) -> dict[str, Any]:
if not site_id or not str(site_id).strip():
raise PusatExportError("site_id wajib diisi.")
site_id = str(site_id).strip()
try:
site = Site.objects.get(pk=site_id)
except Site.DoesNotExist as exc:
raise PusatExportError("site_id tidak ditemukan.") from exc
cycle: Cycle | None = None
if cycle_id is not None and str(cycle_id).strip() != "":
try:
cycle = Cycle.objects.select_related("kandang").get(pk=int(cycle_id))
except (Cycle.DoesNotExist, TypeError, ValueError) as exc:
raise PusatExportError("cycle_id tidak ditemukan.") from exc
if cycle.kandang.site_id != site.pk:
raise PusatExportError("cycle_id tidak termasuk site_id tersebut.")
parsed_from = _parse_optional_date(date_from, "from")
parsed_to = _parse_optional_date(date_to, "to")
if parsed_from is None and parsed_to is None:
if cycle is not None:
parsed_from = cycle.start_date
parsed_to = cycle.end_date or timezone.localdate()
else:
parsed_from, parsed_to = _default_window()
elif parsed_from is None or parsed_to is None:
raise PusatExportError("from dan to harus diisi berpasangan, atau keduanya dikosongkan.")
if parsed_to < parsed_from:
raise PusatExportError("to harus pada atau setelah from.")
page_n, page_size_n = _parse_page(page, page_size)
return {
"site": site,
"cycle": cycle,
"date_from": parsed_from,
"date_to": parsed_to,
"page": page_n,
"page_size": page_size_n,
}
def _enrich_cycle_initial_balance(cycle: Cycle, row: dict[str, Any]) -> dict[str, Any]:
"""Attach live IoT saldo-awal comparison fields (same source as initial-balance API)."""
balance_date = cycle.feed_initial_balance_date
if balance_date is None:
row["feed_initial_balance_iot"] = None
row["feed_initial_balance_accuracy"] = None
row["feed_initial_balance_sync_error"] = None
return row
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
manual = cycle.feed_initial_balance
row["feed_initial_balance_iot"] = iot_in
row["feed_initial_balance_accuracy"] = initial_balance_accuracy(manual, iot_in)
row["feed_initial_balance_sync_error"] = sync_error
return row
def build_ops_export(
*,
site: Site,
cycle: Cycle | None,
date_from: date,
date_to: date,
page: int,
page_size: int,
) -> dict[str, Any]:
kandangs_qs = Kandang.objects.filter(site=site).order_by("kandang_id")
cycles_qs = Cycle.objects.filter(kandang__site=site).select_related("kandang")
if cycle is not None:
kandangs_qs = kandangs_qs.filter(pk=cycle.kandang_id)
cycles_qs = cycles_qs.filter(pk=cycle.pk)
sites_tree: list[dict[str, Any]] = []
site_node = _site_payload(site)
kandang_nodes = []
cycles_by_kandang: dict[int, list[Cycle]] = {}
for c in cycles_qs.order_by("cycle_id"):
cycles_by_kandang.setdefault(c.kandang_id, []).append(c)
for kandang in kandangs_qs:
node = _kandang_payload(kandang, include_feed_urls=True)
cycle_rows = []
for c in cycles_by_kandang.get(kandang.pk, []):
row = CycleSerializer(c).data
cycle_rows.append(_enrich_cycle_initial_balance(c, dict(row)))
node["cycles"] = cycle_rows
kandang_nodes.append(node)
site_node["kandangs"] = kandang_nodes
sites_tree.append(site_node)
series_filter = {"cycle__kandang__site_id": site.pk, "date__gte": date_from, "date__lte": date_to}
if cycle is not None:
series_filter["cycle_id"] = cycle.pk
def series(model, serializer_class, order_fields=("date", "pk")):
qs = (
model.objects.filter(**series_filter)
.select_related("cycle", "cycle__kandang")
.order_by(*order_fields)
)
return _serialize_page(
qs, serializer_class, page=page, page_size=page_size, site_id=site.pk
)
return {
"exported_at": timezone.now().isoformat(),
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"cycle_id": cycle.pk if cycle else None,
"site_id": site.pk,
"page": page,
"page_size": page_size,
"sites": sites_tree,
"data": {
"kpis": series(KPI, KPISerializer),
"manual_inputs": series(ManualInput, ManualInputSerializer),
"feed_sacks": series(FeedSacks, FeedSacksSerializer),
"chicken_countings": series(ChickenCounting, ChickenCountingSerializer),
"chicken_weights": series(ChickenWeight, ChickenWeightSerializer),
"ai_insights": series(AIInsight, AIInsightSerializer),
},
}
def resolve_iot_export_params(
*,
site_id: str | None,
kandang_id: str | None,
flock_id: str | None,
date_from: str | None,
date_to: str | None,
page: str | None,
page_size: str | None,
) -> dict[str, Any]:
if not site_id or not str(site_id).strip():
raise PusatExportError("site_id wajib diisi.")
site_id = str(site_id).strip()
try:
site = Site.objects.get(pk=site_id)
except Site.DoesNotExist as exc:
raise PusatExportError("site_id tidak ditemukan.") from exc
kandang: Kandang | None = None
if kandang_id is not None and str(kandang_id).strip() != "":
try:
kandang = Kandang.objects.get(pk=int(kandang_id))
except (Kandang.DoesNotExist, TypeError, ValueError) as exc:
raise PusatExportError("kandang_id tidak ditemukan.") from exc
if kandang.site_id != site.pk:
raise PusatExportError("kandang_id tidak termasuk site_id tersebut.")
flock: Flock | None = None
if flock_id is not None and str(flock_id).strip() != "":
try:
flock = Flock.objects.select_related("kandang").get(pk=int(flock_id))
except (Flock.DoesNotExist, TypeError, ValueError) as exc:
raise PusatExportError("flock_id tidak ditemukan.") from exc
if flock.kandang.site_id != site.pk:
raise PusatExportError("flock_id tidak termasuk site_id tersebut.")
if kandang is not None and flock.kandang_id != kandang.pk:
raise PusatExportError("flock_id tidak termasuk kandang_id tersebut.")
parsed_from = _parse_optional_date(date_from, "from")
parsed_to = _parse_optional_date(date_to, "to")
if parsed_from is None and parsed_to is None:
parsed_from, parsed_to = _default_window()
elif parsed_from is None or parsed_to is None:
raise PusatExportError("from dan to harus diisi berpasangan, atau keduanya dikosongkan.")
if parsed_to < parsed_from:
raise PusatExportError("to harus pada atau setelah from.")
page_n, page_size_n = _parse_page(page, page_size)
return {
"site": site,
"kandang": kandang,
"flock": flock,
"date_from": parsed_from,
"date_to": parsed_to,
"page": page_n,
"page_size": page_size_n,
}
def build_iot_export(
*,
site: Site,
kandang: Kandang | None,
flock: Flock | None,
date_from: date,
date_to: date,
page: int,
page_size: int,
) -> dict[str, Any]:
kandangs_qs = Kandang.objects.filter(site=site).order_by("kandang_id")
flocks_qs = Flock.objects.filter(kandang__site=site).select_related("kandang")
if kandang is not None:
kandangs_qs = kandangs_qs.filter(pk=kandang.pk)
flocks_qs = flocks_qs.filter(kandang_id=kandang.pk)
if flock is not None:
kandangs_qs = kandangs_qs.filter(pk=flock.kandang_id)
flocks_qs = flocks_qs.filter(pk=flock.pk)
flocks_by_kandang: dict[int, list[Flock]] = {}
for f in flocks_qs.order_by("flock_id"):
flocks_by_kandang.setdefault(f.kandang_id, []).append(f)
site_node = _site_payload(site)
kandang_nodes = []
for k in kandangs_qs:
node = _kandang_payload(k, include_feed_urls=False)
node["flocks"] = [
{
"flock_id": f.pk,
"flock_name": f.flock_name,
"created_at": f.created_at,
"updated_at": f.updated_at,
}
for f in flocks_by_kandang.get(k.pk, [])
]
kandang_nodes.append(node)
site_node["kandangs"] = kandang_nodes
iot_qs = (
IotPanel.objects.filter(
flock__kandang__site_id=site.pk,
date__gte=date_from,
date__lte=date_to,
)
.select_related("flock", "flock__kandang")
.order_by("timestamp", "panel_id")
)
if kandang is not None:
iot_qs = iot_qs.filter(flock__kandang_id=kandang.pk)
if flock is not None:
iot_qs = iot_qs.filter(flock_id=flock.pk)
iot_page = _serialize_page(
iot_qs, IotPanelSerializer, page=page, page_size=page_size, site_id=site.pk
)
for row in iot_page["results"]:
# Serializer already exposes flock_id / kandang; ensure site_id present.
row.setdefault("site_id", site.pk)
return {
"exported_at": timezone.now().isoformat(),
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"flock_id": flock.pk if flock else None,
"kandang_id": kandang.pk if kandang else None,
"site_id": site.pk,
"page": page,
"page_size": page_size,
"sites": [site_node],
"data": {"iot_panels": iot_page},
}
@@ -45,13 +45,16 @@ def register_site_to_pusat(
Change code after register: allow_code_change=True (requires existing registration). Change code after register: allow_code_change=True (requires existing registration).
""" """
base = (settings.PUSAT_BASE_URL or "").rstrip("/") base = (settings.PUSAT_BASE_URL or "").rstrip("/")
api_key = (settings.PUSAT_API_KEY or "").strip() pusat_key = (settings.PUSAT_API_KEY or "").strip()
site_key = (settings.SITE_API_KEY or "").strip()
site_api = (settings.SITE_API_BASE_URL or "").rstrip("/") site_api = (settings.SITE_API_BASE_URL or "").rstrip("/")
if not base: if not base:
raise PusatRegisterError("PUSAT_BASE_URL belum dikonfigurasi.") raise PusatRegisterError("PUSAT_BASE_URL belum dikonfigurasi.")
if not api_key: if not pusat_key:
raise PusatRegisterError("PUSAT_API_KEY belum dikonfigurasi.") raise PusatRegisterError("PUSAT_API_KEY belum dikonfigurasi.")
if not site_key:
raise PusatRegisterError("SITE_API_KEY belum dikonfigurasi.")
if not site_api: if not site_api:
raise PusatRegisterError("SITE_API_BASE_URL belum dikonfigurasi.") raise PusatRegisterError("SITE_API_BASE_URL belum dikonfigurasi.")
@@ -75,9 +78,9 @@ def register_site_to_pusat(
"site_id": site.pk, "site_id": site.pk,
"name": site.site_name, "name": site.site_name,
"api_base_url": site_api, "api_base_url": site_api,
"api_key": api_key, "api_key": site_key,
} }
headers = {"X-API-Key": api_key, "Content-Type": "application/json"} headers = {"X-API-Key": pusat_key, "Content-Type": "application/json"}
try: try:
with httpx.Client(timeout=30) as client: with httpx.Client(timeout=30) as client:
+13 -8
View File
@@ -190,7 +190,7 @@ class FarmAPITests(TestCase):
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE) self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
def test_apply_close_decision_approve(self): def test_apply_close_decision_approve(self):
from django.test import override_settings from apps.accounts.models import ApiKey
start = date(2026, 8, 1) start = date(2026, 8, 1)
end = start + timedelta(days=34) end = start + timedelta(days=34)
@@ -204,13 +204,18 @@ class FarmAPITests(TestCase):
status=Cycle.STATUS_PENDING_CLOSE, status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=end, proposed_end_date=end,
) )
with override_settings(PUSAT_API_KEY="pusat-test-key"): site_key = "site-close-callback-key"
response = self.client.post( api_key, _ = ApiKey.generate(self.user, "site")
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/", api_key.prefix = site_key[:8]
{"decision": "approve", "end_date": end.isoformat()}, api_key.key_hash = ApiKey.hash_key(site_key)
format="json", api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
HTTP_X_API_KEY="pusat-test-key",
) response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
{"decision": "approve", "end_date": end.isoformat()},
format="json",
HTTP_X_API_KEY=site_key,
)
self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED) self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
self.assertEqual(response.data["end_date"], end.isoformat()) self.assertEqual(response.data["end_date"], end.isoformat())
+218
View File
@@ -0,0 +1,218 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import ApiKey, User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import AIInsight, ChickenCounting, FeedSacks, IotPanel
SITE_KEY = "site-export-test-key"
@override_settings(DASHBOARD_PUBLISH_HOUR=17, TIME_ZONE="Asia/Jakarta")
class PusatExportTests(TestCase):
def setUp(self):
self.client = APIClient()
self.admin = User.objects.create_user(
user_name="export-admin",
password="x",
status=User.STATUS_SUPERADMIN,
)
api_key, _ = ApiKey.generate(self.admin, "site")
api_key.prefix = SITE_KEY[:8]
api_key.key_hash = ApiKey.hash_key(SITE_KEY)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.site = Site.objects.create(site_name="ExportSite", user=self.admin)
self.other_site = Site.objects.create(site_name="OtherSite", user=self.admin)
self.kandang = Kandang.objects.create(kandang_name="K1", site=self.site)
self.other_kandang = Kandang.objects.create(kandang_name="K2", site=self.other_site)
self.flock = Flock.objects.create(flock_name="F1", kandang=self.kandang)
self.other_flock = Flock.objects.create(flock_name="F2", kandang=self.other_kandang)
today = timezone.localdate()
self.cycle = Cycle.objects.create(
kandang=self.kandang,
start_date=today - timedelta(days=10),
doc_in_weight=40,
doc_in_count=1000,
status=Cycle.STATUS_ACTIVE,
)
self.other_cycle = Cycle.objects.create(
kandang=self.other_kandang,
start_date=today - timedelta(days=10),
doc_in_weight=40,
doc_in_count=1000,
status=Cycle.STATUS_ACTIVE,
)
# "Today" rows would be hidden by publish cutoff on normal list APIs before 17:00.
FeedSacks.objects.create(cycle=self.cycle, date=today, in_today=1)
FeedSacks.objects.create(cycle=self.other_cycle, date=today, in_today=2)
ChickenCounting.objects.create(
cycle=self.cycle, date=today, total_count=100, mortality_count=1
)
AIInsight.objects.create(
cycle=self.cycle,
date=today,
insight_text="ok",
alert="none",
section="fcr",
session="morning",
)
IotPanel.objects.create(
flock=self.flock,
date=today,
timestamp=timezone.now(),
wind_speed=1.0,
humidity=50.0,
water_total=10.0,
average_temperature=28.0,
experience_temperature=27.0,
)
IotPanel.objects.create(
flock=self.other_flock,
date=today,
timestamp=timezone.now(),
wind_speed=1.0,
humidity=50.0,
water_total=10.0,
average_temperature=28.0,
experience_temperature=27.0,
)
def _auth(self):
self.client.credentials(HTTP_X_API_KEY=SITE_KEY)
def test_ops_requires_auth(self):
response = self.client.get("/api/v1/pusat/export/", {"site_id": self.site.pk})
self.assertEqual(response.status_code, 401)
def test_ops_requires_site_id(self):
self._auth()
response = self.client.get("/api/v1/pusat/export/")
self.assertEqual(response.status_code, 400)
def test_ops_unknown_site(self):
self._auth()
response = self.client.get("/api/v1/pusat/export/", {"site_id": "missing-9999"})
self.assertEqual(response.status_code, 400)
def test_ops_cycle_site_mismatch(self):
self._auth()
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": self.other_cycle.pk},
)
self.assertEqual(response.status_code, 400)
def test_ops_export_scopes_and_includes_today(self):
self._auth()
today = timezone.localdate().isoformat()
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "from": today, "to": today},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["site_id"], self.site.pk)
self.assertEqual(len(response.data["sites"]), 1)
self.assertNotIn("flocks", response.data["sites"][0]["kandangs"][0])
self.assertNotIn("iot_panels", response.data["data"])
self.assertEqual(response.data["data"]["feed_sacks"]["count"], 1)
self.assertEqual(response.data["data"]["chicken_countings"]["count"], 1)
self.assertEqual(response.data["data"]["ai_insights"]["count"], 1)
self.assertEqual(
response.data["data"]["feed_sacks"]["results"][0]["site_id"], self.site.pk
)
def test_ops_cycle_id_defaults_to_cycle_span(self):
self._auth()
closed = Cycle.objects.create(
kandang=self.kandang,
start_date=date(2026, 1, 1),
end_date=date(2026, 2, 10),
doc_in_weight=40,
doc_in_count=1000,
status=Cycle.STATUS_CLOSED,
)
FeedSacks.objects.create(cycle=closed, date=date(2026, 1, 15), in_today=3)
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": closed.pk},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["from"], "2026-01-01")
self.assertEqual(response.data["to"], "2026-02-10")
self.assertEqual(response.data["data"]["feed_sacks"]["count"], 1)
def test_ops_export_includes_initial_balance_iot(self):
self._auth()
today = timezone.localdate()
self.cycle.feed_initial_balance = 120
self.cycle.feed_initial_balance_date = today
self.cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
with patch(
"apps.farms.services.pusat_export.pull_initial_balance_iot",
return_value=(115, None),
) as mock_pull:
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": self.cycle.pk},
)
self.assertEqual(response.status_code, 200, response.data)
cycle_row = response.data["sites"][0]["kandangs"][0]["cycles"][0]
self.assertEqual(cycle_row["feed_initial_balance"], 120)
self.assertEqual(cycle_row["feed_initial_balance_date"], today.isoformat())
self.assertEqual(cycle_row["feed_initial_balance_iot"], 115)
self.assertEqual(cycle_row["feed_initial_balance_accuracy"], 95.8)
self.assertIsNone(cycle_row["feed_initial_balance_sync_error"])
mock_pull.assert_called_once_with(self.cycle, today)
def test_ops_export_skips_iot_pull_without_balance_date(self):
self._auth()
with patch(
"apps.farms.services.pusat_export.pull_initial_balance_iot",
return_value=(99, None),
) as mock_pull:
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": self.cycle.pk},
)
self.assertEqual(response.status_code, 200, response.data)
cycle_row = response.data["sites"][0]["kandangs"][0]["cycles"][0]
self.assertIsNone(cycle_row["feed_initial_balance_iot"])
self.assertIsNone(cycle_row["feed_initial_balance_accuracy"])
self.assertIsNone(cycle_row["feed_initial_balance_sync_error"])
mock_pull.assert_not_called()
def test_iot_flock_site_mismatch(self):
self._auth()
response = self.client.get(
"/api/v1/pusat/export/iot/",
{"site_id": self.site.pk, "flock_id": self.other_flock.pk},
)
self.assertEqual(response.status_code, 400)
def test_iot_export_structure_and_panels(self):
self._auth()
today = timezone.localdate().isoformat()
response = self.client.get(
"/api/v1/pusat/export/iot/",
{"site_id": self.site.pk, "from": today, "to": today},
)
self.assertEqual(response.status_code, 200, response.data)
site_node = response.data["sites"][0]
self.assertEqual(site_node["site_id"], self.site.pk)
self.assertIn("flocks", site_node["kandangs"][0])
self.assertNotIn("cycles", site_node["kandangs"][0])
self.assertEqual(response.data["data"]["iot_panels"]["count"], 1)
self.assertEqual(
response.data["data"]["iot_panels"]["results"][0]["site_id"], self.site.pk
)
@@ -10,6 +10,7 @@ from apps.farms.models import Site
@override_settings( @override_settings(
PUSAT_BASE_URL="http://pusat.example/api/v1", PUSAT_BASE_URL="http://pusat.example/api/v1",
PUSAT_API_KEY="shared-key", PUSAT_API_KEY="shared-key",
SITE_API_KEY="site-machine-key",
SITE_API_BASE_URL="https://site.example/api/v1", SITE_API_BASE_URL="https://site.example/api/v1",
) )
class PusatRegisterTests(TestCase): class PusatRegisterTests(TestCase):
@@ -51,6 +52,7 @@ class PusatRegisterTests(TestCase):
self.assertEqual(kwargs["json"]["api_base_url"], "https://site.example/api/v1") self.assertEqual(kwargs["json"]["api_base_url"], "https://site.example/api/v1")
self.assertEqual(kwargs["json"]["name"], "Sukawarna") self.assertEqual(kwargs["json"]["name"], "Sukawarna")
self.assertEqual(kwargs["json"]["site_id"], self.site.pk) self.assertEqual(kwargs["json"]["site_id"], self.site.pk)
self.assertEqual(kwargs["json"]["api_key"], "site-machine-key")
self.assertEqual(kwargs["headers"]["X-API-Key"], "shared-key") self.assertEqual(kwargs["headers"]["X-API-Key"], "shared-key")
def test_cannot_register_when_already_registered(self): def test_cannot_register_when_already_registered(self):
+4
View File
@@ -5,6 +5,8 @@ from apps.farms.views import (
CycleViewSet, CycleViewSet,
FlockViewSet, FlockViewSet,
KandangViewSet, KandangViewSet,
PusatIotExportView,
PusatOpsExportView,
SiteViewSet, SiteViewSet,
pusat_change_code, pusat_change_code,
pusat_register, pusat_register,
@@ -21,5 +23,7 @@ urlpatterns = [
path("pusat/register/", pusat_register, name="pusat-register"), path("pusat/register/", pusat_register, name="pusat-register"),
path("pusat/change-code/", pusat_change_code, name="pusat-change-code"), path("pusat/change-code/", pusat_change_code, name="pusat-change-code"),
path("pusat/registration/", pusat_registration_status, name="pusat-registration-status"), path("pusat/registration/", pusat_registration_status, name="pusat-registration-status"),
path("pusat/export/", PusatOpsExportView.as_view(), name="pusat-export-ops"),
path("pusat/export/iot/", PusatIotExportView.as_view(), name="pusat-export-iot"),
*router.urls, *router.urls,
] ]
+67 -20
View File
@@ -1,16 +1,22 @@
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
import secrets from apps.accounts.authentication import ApiKeyAuthentication
from apps.accounts.models import User from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.pusat_close import PusatCloseError, notify_pusat_close_request from apps.farms.services.pusat_close import PusatCloseError, notify_pusat_close_request
from apps.farms.services.pusat_export import (
PusatExportError,
build_iot_export,
build_ops_export,
resolve_iot_export_params,
resolve_ops_export_params,
)
from apps.farms.services.pusat_register import PusatRegisterError, register_site_to_pusat from apps.farms.services.pusat_register import PusatRegisterError, register_site_to_pusat
from apps.operations.services.feed_sacks import ( from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows, cleanup_opening_balance_feed_rows,
@@ -30,23 +36,19 @@ def _validation_detail(exc: ValidationError) -> str | dict:
return str(exc.message) if hasattr(exc, "message") else str(exc) return str(exc.message) if hasattr(exc, "message") else str(exc)
def _pusat_api_key_from_request(request) -> str | None: class IsApiKeyAuthenticated(permissions.BasePermission):
return request.headers.get("X-API-Key") or request.META.get("HTTP_X_API_KEY") """Require authentication via hashed site ApiKey (reject session-only users)."""
message = "Site API key required."
class IsPusatCloseCallback(permissions.BasePermission):
"""Allow only the shared pusat callback key (not session users)."""
message = "Pusat API key required."
def has_permission(self, request, view): def has_permission(self, request, view):
expected = (getattr(settings, "PUSAT_API_KEY", "") or "").strip() from apps.accounts.models import ApiKey
if not expected:
return False return bool(
provided = (_pusat_api_key_from_request(request) or "").strip() request.user
if not provided: and request.user.is_authenticated
return False and isinstance(getattr(request, "auth", None), ApiKey)
return secrets.compare_digest(provided, expected) )
def _revert_pending_close(cycle: Cycle) -> None: def _revert_pending_close(cycle: Cycle) -> None:
@@ -286,11 +288,11 @@ class CycleViewSet(viewsets.ModelViewSet):
detail=True, detail=True,
methods=["post"], methods=["post"],
url_path="apply-close-decision", url_path="apply-close-decision",
authentication_classes=[], authentication_classes=[ApiKeyAuthentication],
permission_classes=[IsPusatCloseCallback], permission_classes=[IsApiKeyAuthenticated],
) )
def apply_close_decision(self, request, pk=None): def apply_close_decision(self, request, pk=None):
"""Machine endpoint: pusat pushes approve/reject via shared PUSAT_API_KEY.""" """Machine endpoint: pusat pushes approve/reject with the site SITE_API_KEY."""
cycle = self.get_object() cycle = self.get_object()
decision = str(request.data.get("decision", "")).strip().lower() decision = str(request.data.get("decision", "")).strip().lower()
if decision not in {"approve", "reject"}: if decision not in {"approve", "reject"}:
@@ -434,3 +436,48 @@ class CycleViewSet(viewsets.ModelViewSet):
"sync_error": sync_error, "sync_error": sync_error,
} }
) )
class PusatOpsExportView(APIView):
"""Machine pull: sites → kandangs → cycles + ops time-series (no publish cutoff)."""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
params = request.query_params
try:
resolved = resolve_ops_export_params(
site_id=params.get("site_id"),
cycle_id=params.get("cycle_id"),
date_from=params.get("from"),
date_to=params.get("to"),
page=params.get("page"),
page_size=params.get("page_size"),
)
payload = build_ops_export(**resolved)
except PusatExportError as exc:
return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST)
return Response(payload)
class PusatIotExportView(APIView):
"""Machine pull: sites → kandangs → flocks + iot_panels (no publish cutoff)."""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
params = request.query_params
try:
resolved = resolve_iot_export_params(
site_id=params.get("site_id"),
kandang_id=params.get("kandang_id"),
flock_id=params.get("flock_id"),
date_from=params.get("from"),
date_to=params.get("to"),
page=params.get("page"),
page_size=params.get("page_size"),
)
payload = build_iot_export(**resolved)
except PusatExportError as exc:
return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST)
return Response(payload)
@@ -109,13 +109,13 @@ class Command(BaseCommand):
stdout=self.stdout, stdout=self.stdout,
) )
bootstrap_key = settings.BOOTSTRAP_API_KEY bootstrap_key = settings.SITE_API_KEY
if bootstrap_key: if bootstrap_key:
api_key, _ = ApiKey.generate(admin, "bootstrap") api_key, _ = ApiKey.generate(admin, "site")
api_key.prefix = bootstrap_key[:8] api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key) api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})") self.stdout.write(f"Site API key installed (prefix={api_key.prefix})")
accounts = admin_user_name accounts = admin_user_name
if staff_user_name: if staff_user_name:
@@ -35,14 +35,14 @@ class Command(BaseCommand):
user.save(update_fields=["display_name", "updated_at"]) user.save(update_fields=["display_name", "updated_at"])
raw_key = None raw_key = None
bootstrap = settings.BOOTSTRAP_API_KEY bootstrap = settings.SITE_API_KEY
if bootstrap: if bootstrap:
api_key, raw_key = ApiKey.generate(user, "bootstrap") api_key, raw_key = ApiKey.generate(user, "site")
api_key.prefix = bootstrap[:8] api_key.prefix = bootstrap[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap) api_key.key_hash = ApiKey.hash_key(bootstrap)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
raw_key = bootstrap raw_key = bootstrap
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})") self.stdout.write(f"Site API key installed (prefix={api_key.prefix})")
elif not ApiKey.objects.filter(user=user).exists(): elif not ApiKey.objects.filter(user=user).exists():
api_key, raw_key = ApiKey.generate(user, "seed") api_key, raw_key = ApiKey.generate(user, "seed")
self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}")) self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}"))
@@ -0,0 +1,18 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0010_delete_reportsnapshot"),
]
operations = [
migrations.AddConstraint(
model_name="aiinsight",
constraint=models.UniqueConstraint(
fields=("cycle", "date", "section", "session"),
name="uniq_ai_insight_cycle_date_section_session",
),
),
]
+6
View File
@@ -39,6 +39,12 @@ class AIInsight(models.Model):
class Meta: class Meta:
db_table = "ai_insight" db_table = "ai_insight"
ordering = ["-date"] ordering = ["-date"]
constraints = [
models.UniqueConstraint(
fields=["cycle", "date", "section", "session"],
name="uniq_ai_insight_cycle_date_section_session",
)
]
class FeedSacks(models.Model): class FeedSacks(models.Model):
+3 -2
View File
@@ -168,13 +168,14 @@ SPECTACULAR_SETTINGS = {
"VERSION": "1.0.0", "VERSION": "1.0.0",
} }
BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or ""
BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin" BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or "" BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or ""
BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or "" BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or ""
BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or "" BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or ""
# Machine API key for this farm deployment (pusat pull + register body api_key).
SITE_API_KEY = (env("SITE_API_KEY", "") or "").strip()
KARUNG_WEB_ADMIN_BASE_URL = ( KARUNG_WEB_ADMIN_BASE_URL = (
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or "" env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
).rstrip("/") ).rstrip("/")
+2
View File
@@ -195,6 +195,8 @@ CREATE TABLE IF NOT EXISTS ai_insight (
CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id); CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date); CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cycle_date_section_session
ON ai_insight (cycle_id, date, section, session);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 8. Feed Sacks -- 8. Feed Sacks
+17 -13
View File
@@ -22,6 +22,7 @@ import {
import { throughVisibleDate } from '../utils/visibility.ts'; import { throughVisibleDate } from '../utils/visibility.ts';
import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'; import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardCloseCycleControls from './dashboard/DashboardCloseCycleControls.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx'; import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx'; import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx'; import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx';
@@ -229,19 +230,22 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
title="Dashboard" title="Dashboard"
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : cycleStatus === 'pending_close' ? 'menunggu GM' : 'selesai'})`} subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : cycleStatus === 'pending_close' ? 'menunggu GM' : 'selesai'})`}
/> />
<DashboardReportCtas <div className="flex flex-col items-end gap-3 sm:flex-row sm:items-start">
cycleClosed={cycleStatus === 'closed'} <DashboardCloseCycleControls />
hasDailySnapshot={false} <DashboardReportCtas
hasEndCycleSnapshot={false} cycleClosed={cycleStatus === 'closed'}
onOpenDaily={() => { hasDailySnapshot={false}
setReportType('daily'); hasEndCycleSnapshot={false}
setReportOpen(true); onOpenDaily={() => {
}} setReportType('daily');
onOpenEndCycle={() => { setReportOpen(true);
setReportType('end_cycle'); }}
setReportOpen(true); onOpenEndCycle={() => {
}} setReportType('end_cycle');
/> setReportOpen(true);
}}
/>
</div>
</div> </div>
{error && ( {error && (
<div className="mb-4"> <div className="mb-4">
+2 -40
View File
@@ -59,7 +59,6 @@ const SettingsPage: React.FC = () => {
const [flockName, setFlockName] = useState(''); const [flockName, setFlockName] = useState('');
const [editingCycleId, setEditingCycleId] = useState<number | null>(null); const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [startDate, setStartDate] = useState(defaultCycleForm().startDate); const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
const [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount); const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight); const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
const [registerCode, setRegisterCode] = useState(''); const [registerCode, setRegisterCode] = useState('');
@@ -559,8 +558,8 @@ const SettingsPage: React.FC = () => {
</p> </p>
)} )}
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Tanggal selesai tidak diisi di sini. Ajukan tutup siklus setelah produksi selesai; GM Tanggal selesai tidak diisi di sini. Ajukan tutup siklus dari Dashboard setelah
yang menyetujui end date. produksi selesai; GM yang menyetujui end date.
</p> </p>
<label className="block text-sm"> <label className="block text-sm">
Mulai Mulai
@@ -631,43 +630,6 @@ const SettingsPage: React.FC = () => {
</span> </span>
</button> </button>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
{canRequestClose && status === 'active' && (
<div className="flex items-center gap-1">
<input
type="date"
className="border border-gray-300 rounded px-1 py-0.5 text-xs"
min={cycle.start_date}
value={proposedCloseDate}
onChange={(e) => setProposedCloseDate(e.target.value)}
/>
<button
className="text-xs text-red-600 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.requestClose(cycle.id, {
proposed_end_date: proposedCloseDate,
});
flash('Permintaan tutup dikirim ke pusat.');
})
}
>
Ajukan tutup
</button>
</div>
)}
{canRequestClose && status === 'pending_close' && (
<button
className="text-xs text-amber-700 hover:underline"
onClick={() =>
void wrap(async () => {
await api.cycles.cancelCloseRequest(cycle.id);
flash('Permintaan tutup dibatalkan.');
})
}
>
Batalkan permintaan
</button>
)}
<button <button
className="text-xs text-blue-600 hover:underline" className="text-xs text-blue-600 hover:underline"
onClick={() => beginCycleEdit(cycle)} onClick={() => beginCycleEdit(cycle)}
@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import { useAuth } from '../../context/AuthContext.tsx';
import { useFarm } from '../../context/FarmContext.tsx';
import { AlertDialog, Banner, ConfirmDialog } from '../ui/Feedback.tsx';
import { formatDateId, resolveCycleStatus, todayIso } from '../../utils/format.ts';
type ConfirmKind = 'request_close' | 'cancel_request';
const DashboardCloseCycleControls: React.FC = () => {
const { user } = useAuth();
const { selectedCycle, reload } = useFarm();
const [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
const [confirmKind, setConfirmKind] = useState<ConfirmKind | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const canRequestClose = user?.status === 'active';
const status = selectedCycle ? resolveCycleStatus(selectedCycle) : null;
const showControls = Boolean(canRequestClose && selectedCycle && status !== 'closed');
const run = async (action: () => Promise<void>, successMessage: string) => {
if (!selectedCycle) return;
setBusy(true);
setError(null);
try {
await action();
await reload();
setNotice(successMessage);
setConfirmKind(null);
} catch (err) {
setConfirmKind(null);
setError(errorMessage(err));
void reload();
} finally {
setBusy(false);
}
};
if (!showControls && !error) return null;
return (
<div className="flex flex-col items-end gap-1">
{showControls && notice && <Banner kind="success">{notice}</Banner>}
{showControls && status === 'active' && selectedCycle && (
<div className="flex flex-wrap items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<span className="sr-only">Tanggal usulan tutup</span>
<input
type="date"
className="border border-gray-300 rounded-lg px-2 py-2 text-sm"
min={selectedCycle.start_date}
value={proposedCloseDate}
onChange={(e) => setProposedCloseDate(e.target.value)}
disabled={busy}
/>
</label>
<button
type="button"
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 disabled:opacity-50"
disabled={busy || !proposedCloseDate}
onClick={() => {
setNotice(null);
setError(null);
setConfirmKind('request_close');
}}
>
Ajukan tutup
</button>
</div>
)}
{showControls && status === 'pending_close' && selectedCycle && (
<div className="flex flex-col items-end gap-1">
{selectedCycle.proposed_end_date && (
<p className="text-xs text-amber-700">
Menunggu GM · usulan {formatDateId(selectedCycle.proposed_end_date)}
</p>
)}
<button
type="button"
className="rounded-lg border border-amber-600 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-50 disabled:opacity-50"
disabled={busy}
onClick={() => {
setNotice(null);
setError(null);
setConfirmKind('cancel_request');
}}
>
Batalkan permintaan
</button>
</div>
)}
<ConfirmDialog
open={confirmKind === 'request_close'}
title="Ajukan tutup siklus?"
message={
<p>
Permintaan tutup akan dikirim ke pusat dengan tanggal usulan{' '}
<strong>{formatDateId(proposedCloseDate)}</strong>. GM harus menyetujui sebelum
siklus ditutup. Anda masih bisa membatalkan selama status menunggu GM.
</p>
}
confirmLabel="Ya, ajukan tutup"
busy={busy}
onCancel={() => {
if (!busy) setConfirmKind(null);
}}
onConfirm={() => {
if (busy || !selectedCycle) return;
void run(
() =>
api.cycles
.requestClose(selectedCycle.id, {
proposed_end_date: proposedCloseDate,
})
.then(() => undefined),
'Permintaan tutup dikirim ke pusat.'
);
}}
/>
<ConfirmDialog
open={confirmKind === 'cancel_request'}
title="Batalkan permintaan tutup?"
message={
<p>
Permintaan tutup ke pusat akan dibatalkan. Siklus tetap aktif dan dapat diajukan
tutup lagi nanti.
</p>
}
confirmLabel="Ya, batalkan"
busy={busy}
onCancel={() => {
if (!busy) setConfirmKind(null);
}}
onConfirm={() => {
if (busy || !selectedCycle) return;
void run(
() => api.cycles.cancelCloseRequest(selectedCycle.id).then(() => undefined),
'Permintaan tutup dibatalkan.'
);
}}
/>
<AlertDialog
open={error != null}
title="Gagal mengajukan tutup"
kind="error"
message={<p className="whitespace-pre-wrap break-words">{error}</p>}
dismissLabel="Tutup"
onDismiss={() => setError(null)}
/>
</div>
);
};
export default DashboardCloseCycleControls;
@@ -0,0 +1,119 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DashboardCloseCycleControls from '../DashboardCloseCycleControls.tsx';
const {
requestCloseMock,
cancelCloseRequestMock,
reloadMock,
farmState,
authState,
} = vi.hoisted(() => ({
requestCloseMock: vi.fn().mockResolvedValue({}),
cancelCloseRequestMock: vi.fn().mockResolvedValue({}),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
selectedCycle: {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'active',
proposed_end_date: null,
visible_through_date: '2026-09-10',
} as Record<string, unknown> | null,
},
authState: {
user: {
id: 1,
user_name: 'staff',
display_name: 'Staff',
status: 'active',
},
},
}));
vi.mock('../../../services/apiClient.ts', () => ({
api: {
cycles: {
requestClose: requestCloseMock,
cancelCloseRequest: cancelCloseRequestMock,
},
},
errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'),
}));
vi.mock('../../../context/AuthContext.tsx', () => ({
useAuth: () => authState,
}));
vi.mock('../../../context/FarmContext.tsx', () => ({
useFarm: () => ({
selectedCycle: farmState.selectedCycle,
reload: reloadMock,
}),
}));
describe('DashboardCloseCycleControls', () => {
beforeEach(() => {
requestCloseMock.mockClear();
cancelCloseRequestMock.mockClear();
reloadMock.mockClear();
authState.user.status = 'active';
farmState.selectedCycle = {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'active',
proposed_end_date: null,
visible_through_date: '2026-09-10',
};
});
it('shows date + Ajukan tutup for active cycle and requires confirm before API', async () => {
render(<DashboardCloseCycleControls />);
expect(screen.getByRole('button', { name: /Ajukan tutup/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Ajukan tutup/i }));
expect(requestCloseMock).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Ya, ajukan tutup/i }));
await waitFor(() => {
expect(requestCloseMock).toHaveBeenCalledWith(5, {
proposed_end_date: expect.any(String),
});
});
expect(reloadMock).toHaveBeenCalled();
});
it('shows cancel control when pending_close', () => {
farmState.selectedCycle = {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'pending_close',
proposed_end_date: '2026-09-15',
visible_through_date: '2026-09-10',
};
render(<DashboardCloseCycleControls />);
expect(screen.queryByRole('button', { name: /Ajukan tutup/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Batalkan permintaan/i })).toBeInTheDocument();
});
it('hides controls for non-active users', () => {
authState.user.status = 'superadmin';
render(<DashboardCloseCycleControls />);
expect(screen.queryByRole('button', { name: /Ajukan tutup/i })).not.toBeInTheDocument();
});
it('shows API failure in an alert popup', async () => {
requestCloseMock.mockRejectedValueOnce(
new Error('gagal kirim ke pusat — coba lagi (400: {"detail":"Cycle is already closed."})')
);
render(<DashboardCloseCycleControls />);
fireEvent.click(screen.getByRole('button', { name: /Ajukan tutup/i }));
fireEvent.click(screen.getByRole('button', { name: /Ya, ajukan tutup/i }));
expect(await screen.findByRole('alertdialog')).toBeInTheDocument();
expect(screen.getByText(/Cycle is already closed/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Tutup$/i }));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});
+70
View File
@@ -124,3 +124,73 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</div> </div>
); );
}; };
type AlertDialogProps = {
open: boolean;
title: string;
message: React.ReactNode;
dismissLabel?: string;
kind?: 'error' | 'info' | 'success';
onDismiss: () => void;
};
const alertIconStyles = {
error: 'bg-red-600',
info: 'bg-sky-600',
success: 'bg-green-600',
};
const alertIconClass = {
error: 'fa-triangle-exclamation',
info: 'fa-circle-info',
success: 'fa-circle-check',
};
export const AlertDialog: React.FC<AlertDialogProps> = ({
open,
title,
message,
dismissLabel = 'Tutup',
kind = 'error',
onDismiss,
}) => {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-dialog-title"
onClick={onDismiss}
>
<div
className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-4 flex items-start gap-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-white ${alertIconStyles[kind]}`}
>
<i className={`fa-solid ${alertIconClass[kind]}`} aria-hidden />
</div>
<div className="min-w-0 pt-0.5">
<h3 id="alert-dialog-title" className="text-lg font-semibold text-gray-800">
{title}
</h3>
<div className="mt-1 text-sm text-gray-600">{message}</div>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
onClick={onDismiss}
>
{dismissLabel}
</button>
</div>
</div>
</div>
);
};
+1 -1
View File
@@ -65,7 +65,7 @@ services:
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30} CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30}
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-true} CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-true}
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-true} CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-true}
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-test-api-key} SITE_API_KEY: ${SITE_API_KEY:-test-api-key}
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n}
BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw} BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw}
+2 -2
View File
@@ -21,8 +21,8 @@ BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
BOOTSTRAP_STAFF_USER=staff BOOTSTRAP_STAFF_USER=staff
BOOTSTRAP_STAFF_PASSWORD=change_me BOOTSTRAP_STAFF_PASSWORD=change_me
# Optional: fixed API key for scripts (hashed at rest) # Optional: fixed API key for pusat pull + scripts (hashed at rest)
BOOTSTRAP_API_KEY= SITE_API_KEY=
# Pusat (executive) — register + cycle-close # Pusat (executive) — register + cycle-close
PUSAT_BASE_URL= PUSAT_BASE_URL=
+263
View File
@@ -0,0 +1,263 @@
[
{
"id": 1,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-02",
"age": 0,
"mortality": 10,
"mortality_total": 10,
"feed": 7,
"feed_total": 7,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49990,
"chicken_life_percentage": 99.98,
"iot_weight": 35,
"average_harvest_day": 0,
"average_harvest_weight": 35,
"fcr": 0.20004000800160032,
"eef": 0,
"cc": 1,
"cw": 1,
"fs": 6,
"manual": 5,
"created_at": "2026-09-09T08:04:41.824756+00:00",
"updated_at": "2026-09-10T07:56:44.28364+00:00"
},
{
"id": 2,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-03",
"age": 1,
"mortality": 29,
"mortality_total": 39,
"feed": 4,
"feed_total": 11,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49961,
"chicken_life_percentage": 99.922,
"iot_weight": 45.3,
"average_harvest_day": 1,
"average_harvest_weight": 45.3,
"fcr": 0.24301515888795028,
"eef": 1862.6272619014144,
"cc": 2,
"cw": 2,
"fs": 5,
"manual": 6,
"created_at": "2026-09-09T08:04:41.83067+00:00",
"updated_at": "2026-09-10T07:56:44.286462+00:00"
},
{
"id": 3,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-04",
"age": 2,
"mortality": 10,
"mortality_total": 49,
"feed": 5,
"feed_total": 16,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 58.2,
"average_harvest_day": 2,
"average_harvest_weight": 58.2,
"fcr": 0.2751837694411313,
"eef": 1056.438832095405,
"cc": 3,
"cw": 3,
"fs": 39,
"manual": 7,
"created_at": "2026-09-09T08:21:26.912315+00:00",
"updated_at": "2026-09-10T07:56:44.288276+00:00"
},
{
"id": 4,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-05",
"age": 3,
"mortality": 0,
"mortality_total": 49,
"feed": 0,
"feed_total": 16,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 74.3,
"average_harvest_day": 3,
"average_harvest_weight": 74.3,
"fcr": 0.2155544465878041,
"eef": 1147.8489878080409,
"cc": 4,
"cw": 4,
"fs": 40,
"manual": 8,
"created_at": "2026-09-09T08:24:44.096872+00:00",
"updated_at": "2026-09-10T07:56:44.290188+00:00"
},
{
"id": 5,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-06",
"age": 4,
"mortality": 0,
"mortality_total": 49,
"feed": 0,
"feed_total": 16,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 93.7,
"average_harvest_day": 4,
"average_harvest_weight": 93.7,
"fcr": 0.17092524419929395,
"eef": 1369.1390999397308,
"cc": 5,
"cw": 5,
"fs": 42,
"manual": 9,
"created_at": "2026-09-09T09:47:27.141173+00:00",
"updated_at": "2026-09-10T07:56:44.292079+00:00"
},
{
"id": 6,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-07",
"age": 5,
"mortality": 0,
"mortality_total": 49,
"feed": 0,
"feed_total": 16,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 116.3,
"average_harvest_day": 5,
"average_harvest_weight": 116.3,
"fcr": 0.13771019244603477,
"eef": 1687.3990797090848,
"cc": 6,
"cw": 6,
"fs": 43,
"manual": 10,
"created_at": "2026-09-09T09:47:27.145444+00:00",
"updated_at": "2026-09-10T07:56:44.293955+00:00"
},
{
"id": 7,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-08",
"age": 6,
"mortality": 0,
"mortality_total": 49,
"feed": 0,
"feed_total": 16,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 99.5,
"average_harvest_day": 6,
"average_harvest_weight": 99.5,
"fcr": 0.16096176262787784,
"eef": 1029.2557310625107,
"cc": 7,
"cw": 7,
"fs": 44,
"manual": 11,
"created_at": "2026-09-10T03:52:36.73727+00:00",
"updated_at": "2026-09-10T07:56:44.295709+00:00"
},
{
"id": 8,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-09",
"age": 7,
"mortality": 0,
"mortality_total": 49,
"feed": 0,
"feed_total": 16,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 119.6,
"average_harvest_day": 7,
"average_harvest_weight": 119.6,
"fcr": 0.13391049650061743,
"eef": 1274.655141438863,
"cc": 8,
"cw": 8,
"fs": 41,
"manual": 12,
"created_at": "2026-09-10T03:52:36.742558+00:00",
"updated_at": "2026-09-10T07:56:44.297528+00:00"
},
{
"id": 9,
"cycle": 5,
"kandang": 1,
"flock_id": 6,
"date": "2026-09-10",
"age": 8,
"mortality": 0,
"mortality_total": 49,
"feed": 10,
"feed_total": 26,
"harvest": 0,
"harvest_total": 0,
"harvest_weight": 0,
"harvest_weight_total": 0,
"chicken_life": 49951,
"chicken_life_percentage": 99.902,
"iot_weight": 142.1,
"average_harvest_day": 8,
"average_harvest_weight": 142.1,
"fcr": 0.18314922586133003,
"eef": 968.8871283264695,
"cc": 9,
"cw": 9,
"fs": 45,
"manual": 13,
"created_at": "2026-09-10T07:56:44.299685+00:00",
"updated_at": "2026-09-10T07:56:44.299691+00:00"
}
]