update api export for executive and fixing closing cycle flow
This commit is contained in:
1 parent
d0d1ae7d24
commit
07d36081ce
23 files changed
+1378
-101
No files matched your search
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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}"))
|
||||||
|
|||||||
+18
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in new issue
Block a user