updating cycle close request feature to get approval from national dashboard instead auto from system
This commit is contained in:
1 parent
3a868cf1db
commit
e4dbfd0252
31 files changed
+1322
-182
No files matched your search
@@ -35,6 +35,13 @@ BOOTSTRAP_ADMIN_PASSWORD=
|
||||
BOOTSTRAP_STAFF_USER=
|
||||
BOOTSTRAP_STAFF_PASSWORD=
|
||||
|
||||
# Pusat (executive) cycle-close channel
|
||||
# Outbound notify uses these; inbound apply-close-decision requires PUSAT_API_KEY
|
||||
# in X-API-Key (leave blank to skip notify / reject inbound callbacks).
|
||||
PUSAT_BASE_URL=
|
||||
PUSAT_API_KEY=
|
||||
PUSAT_CITY_SITE_ID=
|
||||
|
||||
# karung-web-admin (external)
|
||||
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
|
||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
|
||||
@@ -49,3 +56,10 @@ IOT_API_BASE_URL=https://dashboard.cpsp.id/api/iot/flocks
|
||||
IOT_SYNC_LOOKBACK_MINUTES=20
|
||||
IOT_SYNC_MAX_PAGES=10
|
||||
IOT_FLOCK_ID_MAP={}
|
||||
|
||||
# Chicken counting edge vision API (counting + mortality + weight)
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL=http://192.168.192.106:8090
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=true
|
||||
@@ -0,0 +1,78 @@
|
||||
# Generated manually for GM cycle-close approval request flow
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def null_end_dates_for_open_cycles(apps, schema_editor):
|
||||
Cycle = apps.get_model("farms", "Cycle")
|
||||
Cycle.objects.filter(status="active").update(end_date=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farms", "0014_kandang_feed_in_button_urls"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="cycle",
|
||||
name="chk_cycles_date_range",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="end_date",
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="total_days",
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("active", "Active"),
|
||||
("pending_close", "Pending close"),
|
||||
("closed", "Closed"),
|
||||
],
|
||||
db_index=True,
|
||||
default="active",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="proposed_end_date",
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="close_requested_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="close_requested_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="requested_cycle_closes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="cycle",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("end_date__isnull", True))
|
||||
| models.Q(("end_date__gte", models.F("start_date"))),
|
||||
name="chk_cycles_date_range",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(null_end_dates_for_open_cycles, migrations.RunPython.noop),
|
||||
]
|
||||
+141
-15
@@ -2,6 +2,7 @@ from datetime import date
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
@@ -58,16 +59,18 @@ class Flock(models.Model):
|
||||
|
||||
class Cycle(models.Model):
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_PENDING_CLOSE = "pending_close"
|
||||
STATUS_CLOSED = "closed"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_ACTIVE, "Active"),
|
||||
(STATUS_PENDING_CLOSE, "Pending close"),
|
||||
(STATUS_CLOSED, "Closed"),
|
||||
]
|
||||
|
||||
cycle_id = models.BigAutoField(primary_key=True)
|
||||
total_days = models.IntegerField()
|
||||
total_days = models.IntegerField(default=1)
|
||||
start_date = models.DateField()
|
||||
end_date = models.DateField()
|
||||
end_date = models.DateField(null=True, blank=True)
|
||||
doc_in_weight = models.IntegerField()
|
||||
doc_in_count = models.IntegerField()
|
||||
feed_initial_balance = models.IntegerField(default=0)
|
||||
@@ -76,6 +79,15 @@ class Cycle(models.Model):
|
||||
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
|
||||
)
|
||||
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
|
||||
proposed_end_date = models.DateField(null=True, blank=True)
|
||||
close_requested_at = models.DateTimeField(null=True, blank=True)
|
||||
close_requested_by = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="requested_cycle_closes",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -84,7 +96,8 @@ class Cycle(models.Model):
|
||||
ordering = ["-start_date"]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(end_date__gte=models.F("start_date")),
|
||||
check=models.Q(end_date__isnull=True)
|
||||
| models.Q(end_date__gte=models.F("start_date")),
|
||||
name="chk_cycles_date_range",
|
||||
)
|
||||
]
|
||||
@@ -95,26 +108,137 @@ class Cycle(models.Model):
|
||||
def clean(self) -> None:
|
||||
if self.end_date and self.start_date and self.end_date < self.start_date:
|
||||
raise ValidationError({"end_date": "Must be on or after start_date"})
|
||||
if (
|
||||
self.proposed_end_date
|
||||
and self.start_date
|
||||
and self.proposed_end_date < self.start_date
|
||||
):
|
||||
raise ValidationError(
|
||||
{"proposed_end_date": "Must be on or after start_date"}
|
||||
)
|
||||
|
||||
def resolved_status(self) -> str:
|
||||
"""Close the cycle once dashboard data through the end date is visible."""
|
||||
def soft_end_date(self) -> date:
|
||||
"""Dashboard-facing upper bound: approved end_date, else publish visible-through."""
|
||||
if self.end_date:
|
||||
return self.end_date
|
||||
from apps.operations.services.visibility import visible_through_date
|
||||
|
||||
if visible_through_date() >= self.end_date:
|
||||
return self.STATUS_CLOSED
|
||||
return self.STATUS_ACTIVE
|
||||
return visible_through_date()
|
||||
|
||||
def operational_end_date(self, *, today: date | None = None) -> date:
|
||||
"""Inclusive upper date for edge/IoT history sync — not publish-gated.
|
||||
|
||||
Open cycles sync through calendar today so morning edge data is not
|
||||
skipped until DASHBOARD_PUBLISH_HOUR. Closed cycles stop at end_date.
|
||||
"""
|
||||
if today is None:
|
||||
today = timezone.localdate()
|
||||
if self.end_date:
|
||||
return min(today, self.end_date)
|
||||
return today
|
||||
|
||||
def resolved_status(self) -> str:
|
||||
"""Status is approval-driven — never auto-closed by publish cutoff."""
|
||||
return self.status
|
||||
|
||||
def sync_status(self) -> bool:
|
||||
resolved = self.resolved_status()
|
||||
if self.status == resolved:
|
||||
return False
|
||||
self.status = resolved
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.sync_status()
|
||||
# Only refresh provisional total_days on full saves (not partial close-field updates).
|
||||
if kwargs.get("update_fields") is None:
|
||||
if self.end_date and self.start_date:
|
||||
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
|
||||
elif self.start_date and not self.end_date:
|
||||
soft = self.soft_end_date()
|
||||
self.total_days = max((soft - self.start_date).days + 1, 1)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clear_close_request_fields(self) -> None:
|
||||
self.proposed_end_date = None
|
||||
self.close_requested_at = None
|
||||
self.close_requested_by = None
|
||||
|
||||
def request_close(self, requester: User, proposed_end_date: date) -> None:
|
||||
if requester.status != User.STATUS_ACTIVE:
|
||||
raise ValidationError("Only active users may request cycle close.")
|
||||
if self.status != self.STATUS_ACTIVE:
|
||||
raise ValidationError("Only an active cycle can request close.")
|
||||
if proposed_end_date < self.start_date:
|
||||
raise ValidationError(
|
||||
{"proposed_end_date": "Must be on or after start_date"}
|
||||
)
|
||||
self.status = self.STATUS_PENDING_CLOSE
|
||||
self.proposed_end_date = proposed_end_date
|
||||
self.close_requested_at = timezone.now()
|
||||
self.close_requested_by = requester
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def cancel_close_request(self, actor: User) -> None:
|
||||
if actor.status != User.STATUS_ACTIVE:
|
||||
raise ValidationError("Only active users may cancel a close request.")
|
||||
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
|
||||
return
|
||||
if self.status != self.STATUS_PENDING_CLOSE:
|
||||
raise ValidationError("Cycle has no pending close request.")
|
||||
self.status = self.STATUS_ACTIVE
|
||||
self.clear_close_request_fields()
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def apply_close_decision(self, *, approved: bool, end_date: date | None = None) -> None:
|
||||
if approved:
|
||||
if end_date is None:
|
||||
raise ValidationError("end_date is required when approving close.")
|
||||
if end_date < self.start_date:
|
||||
raise ValidationError({"end_date": "Must be on or after start_date"})
|
||||
self.status = self.STATUS_CLOSED
|
||||
self.end_date = end_date
|
||||
self.total_days = max((end_date - self.start_date).days + 1, 1)
|
||||
self.clear_close_request_fields()
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"end_date",
|
||||
"total_days",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
if self.status == self.STATUS_CLOSED:
|
||||
raise ValidationError("Cycle is already closed.")
|
||||
self.status = self.STATUS_ACTIVE
|
||||
self.clear_close_request_fields()
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def current_day(self) -> int:
|
||||
"""0-based cycle age: start_date is day 0."""
|
||||
@@ -124,8 +248,10 @@ class Cycle(models.Model):
|
||||
if effective < self.start_date:
|
||||
return 0
|
||||
elapsed = (effective - self.start_date).days
|
||||
max_day = max(self.total_days - 1, 0)
|
||||
return min(max(elapsed, 0), max_day)
|
||||
if self.end_date:
|
||||
max_day = max(self.total_days - 1, 0)
|
||||
return min(max(elapsed, 0), max_day)
|
||||
return max(elapsed, 0)
|
||||
|
||||
@property
|
||||
def visible_through_date(self) -> date:
|
||||
|
||||
@@ -75,6 +75,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
|
||||
current_day = serializers.IntegerField(read_only=True)
|
||||
visible_through_date = serializers.DateField(read_only=True)
|
||||
soft_end_date = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
@@ -85,6 +86,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"total_days",
|
||||
"current_day",
|
||||
"visible_through_date",
|
||||
"soft_end_date",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"chick_in_weight",
|
||||
@@ -92,6 +94,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"feed_initial_balance",
|
||||
"feed_initial_balance_date",
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -100,20 +105,30 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"kandang_name",
|
||||
"feed_initial_balance",
|
||||
"feed_initial_balance_date",
|
||||
"end_date",
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"soft_end_date",
|
||||
]
|
||||
|
||||
def get_soft_end_date(self, obj: Cycle):
|
||||
return obj.soft_end_date()
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("status", None)
|
||||
# Defense in depth: end_date is read-only (GM approve only).
|
||||
attrs.pop("end_date", None)
|
||||
if self.instance is None:
|
||||
attrs.setdefault("total_days", 1)
|
||||
start = attrs.get("start_date", getattr(self.instance, "start_date", None))
|
||||
end = attrs.get("end_date", getattr(self.instance, "end_date", None))
|
||||
end = getattr(self.instance, "end_date", None) if self.instance else None
|
||||
if start and end and end < start:
|
||||
raise serializers.ValidationError({"end_date": "Must be on or after start_date."})
|
||||
return attrs
|
||||
|
||||
def to_representation(self, instance):
|
||||
if instance.sync_status():
|
||||
instance.save(update_fields=["status", "updated_at"])
|
||||
return super().to_representation(instance)
|
||||
Whitespace-only changes.
@@ -0,0 +1,74 @@
|
||||
"""Notify pusat about cycle close request / cancel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
|
||||
from apps.farms.models import Cycle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PusatCloseError(Exception):
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _configured() -> bool:
|
||||
return bool(
|
||||
settings.PUSAT_BASE_URL
|
||||
and settings.PUSAT_API_KEY
|
||||
and settings.PUSAT_CITY_SITE_ID
|
||||
)
|
||||
|
||||
|
||||
def notify_pusat_close_request(cycle: Cycle, *, cancelled: bool = False) -> None:
|
||||
"""POST ingest/cancel to pusat. Raises PusatCloseError on failure when configured."""
|
||||
if not _configured():
|
||||
logger.info("Pusat close channel not configured; skipping notify for cycle %s", cycle.pk)
|
||||
return
|
||||
|
||||
base = settings.PUSAT_BASE_URL.rstrip("/")
|
||||
headers = {"X-API-Key": settings.PUSAT_API_KEY, "Content-Type": "application/json"}
|
||||
city_site_id = int(settings.PUSAT_CITY_SITE_ID)
|
||||
|
||||
try:
|
||||
if cancelled:
|
||||
url = f"{base}/cycle-close/cancel/"
|
||||
payload: dict[str, Any] = {
|
||||
"city_site_id": city_site_id,
|
||||
"source_cycle_id": cycle.pk,
|
||||
}
|
||||
else:
|
||||
url = f"{base}/cycle-close/ingest/"
|
||||
payload = {
|
||||
"city_site_id": city_site_id,
|
||||
"source_cycle_id": cycle.pk,
|
||||
"kandang_name": cycle.kandang.kandang_name,
|
||||
"site_name": cycle.kandang.site.site_name,
|
||||
"start_date": cycle.start_date.isoformat(),
|
||||
"proposed_end_date": cycle.proposed_end_date.isoformat()
|
||||
if cycle.proposed_end_date
|
||||
else None,
|
||||
"doc_in_weight": cycle.doc_in_weight,
|
||||
"doc_in_count": cycle.doc_in_count,
|
||||
"total_days": cycle.total_days,
|
||||
"requested_at": cycle.close_requested_at.isoformat()
|
||||
if cycle.close_requested_at
|
||||
else None,
|
||||
}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:300]
|
||||
raise PusatCloseError(f"gagal kirim ke pusat — coba lagi ({response.status_code}: {detail})")
|
||||
except PusatCloseError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
logger.exception("Pusat notify failed for cycle %s", cycle.pk)
|
||||
raise PusatCloseError("gagal kirim ke pusat — coba lagi") from exc
|
||||
+111
-7
@@ -64,11 +64,12 @@ class FarmAPITests(TestCase):
|
||||
def test_update_cycle(self):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
original_end = start + timedelta(days=34)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
end_date=original_end,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
@@ -86,20 +87,22 @@ class FarmAPITests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.end_date, new_end)
|
||||
self.assertEqual(cycle.total_days, 40)
|
||||
# end_date is GM-approval only — client writes are ignored; total_days
|
||||
# is recomputed from the existing end_date on full save.
|
||||
self.assertEqual(cycle.end_date, original_end)
|
||||
self.assertEqual(cycle.total_days, 35)
|
||||
self.assertEqual(cycle.doc_in_count, 9500)
|
||||
self.assertEqual(cycle.doc_in_weight, 42)
|
||||
|
||||
@patch("apps.operations.services.visibility.visible_through_date", return_value=date(2026, 9, 10))
|
||||
def test_cycle_status_closes_when_end_date_is_visible(self, _mock_visible):
|
||||
def test_cycle_stays_active_without_gm_approval(self, _mock_visible):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
@@ -108,9 +111,110 @@ class FarmAPITests(TestCase):
|
||||
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
|
||||
self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_CLOSED)
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
|
||||
|
||||
def test_active_user_can_request_and_cancel_close(self):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
proposed = start + timedelta(days=34)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/request-close/",
|
||||
{"proposed_end_date": proposed.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
body = response.data.get("cycle", response.data)
|
||||
self.assertEqual(body["status"], Cycle.STATUS_PENDING_CLOSE)
|
||||
self.assertEqual(body["proposed_end_date"], proposed.isoformat())
|
||||
|
||||
cancel = self.client.post(f"/api/v1/cycles/{cycle.pk}/cancel-close-request/", {}, format="json")
|
||||
self.assertEqual(cancel.status_code, 200, cancel.data)
|
||||
cancel_body = cancel.data.get("cycle", cancel.data)
|
||||
self.assertEqual(cancel_body["status"], Cycle.STATUS_ACTIVE)
|
||||
self.assertIsNone(cancel_body["proposed_end_date"])
|
||||
|
||||
def test_superadmin_cannot_request_close(self):
|
||||
self.client.force_authenticate(self.admin)
|
||||
start = date(2026, 8, 1)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/request-close/",
|
||||
{"proposed_end_date": (start + timedelta(days=30)).isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400, response.data)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
|
||||
|
||||
def test_session_user_cannot_apply_close_decision(self):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
end = start + timedelta(days=34)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_PENDING_CLOSE,
|
||||
proposed_end_date=end,
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
|
||||
{"decision": "approve", "end_date": end.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.data)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
|
||||
|
||||
def test_apply_close_decision_approve(self):
|
||||
from django.test import override_settings
|
||||
|
||||
start = date(2026, 8, 1)
|
||||
end = start + timedelta(days=34)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_PENDING_CLOSE,
|
||||
proposed_end_date=end,
|
||||
)
|
||||
with override_settings(PUSAT_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="pusat-test-key",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
|
||||
self.assertEqual(response.data["end_date"], end.isoformat())
|
||||
self.assertEqual(response.data["total_days"], 35)
|
||||
|
||||
def test_flock_persists_across_cycles(self):
|
||||
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
|
||||
|
||||
+150
-1
@@ -1,11 +1,16 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.dateparse import parse_date
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
import secrets
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.farms.models import Cycle, Flock, Kandang, Site
|
||||
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
|
||||
from apps.farms.services.pusat_close import PusatCloseError, notify_pusat_close_request
|
||||
from apps.operations.services.feed_sacks import (
|
||||
cleanup_opening_balance_feed_rows,
|
||||
recompute_feed_sack_in_totals,
|
||||
@@ -16,6 +21,69 @@ from apps.operations.services.karung_web import (
|
||||
)
|
||||
|
||||
|
||||
def _validation_detail(exc: ValidationError) -> str | dict:
|
||||
if hasattr(exc, "message_dict"):
|
||||
return exc.message_dict
|
||||
if hasattr(exc, "messages"):
|
||||
return "; ".join(str(m) for m in exc.messages)
|
||||
return str(exc.message) if hasattr(exc, "message") else str(exc)
|
||||
|
||||
|
||||
def _pusat_api_key_from_request(request) -> str | None:
|
||||
return request.headers.get("X-API-Key") or request.META.get("HTTP_X_API_KEY")
|
||||
|
||||
|
||||
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):
|
||||
expected = (getattr(settings, "PUSAT_API_KEY", "") or "").strip()
|
||||
if not expected:
|
||||
return False
|
||||
provided = (_pusat_api_key_from_request(request) or "").strip()
|
||||
if not provided:
|
||||
return False
|
||||
return secrets.compare_digest(provided, expected)
|
||||
|
||||
|
||||
def _revert_pending_close(cycle: Cycle) -> None:
|
||||
cycle.status = Cycle.STATUS_ACTIVE
|
||||
cycle.clear_close_request_fields()
|
||||
cycle.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _restore_pending_close(
|
||||
cycle: Cycle,
|
||||
*,
|
||||
proposed_end_date,
|
||||
close_requested_at,
|
||||
close_requested_by_id,
|
||||
) -> None:
|
||||
cycle.status = Cycle.STATUS_PENDING_CLOSE
|
||||
cycle.proposed_end_date = proposed_end_date
|
||||
cycle.close_requested_at = close_requested_at
|
||||
cycle.close_requested_by_id = close_requested_by_id
|
||||
cycle.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class IsSuperAdminForWrite(permissions.BasePermission):
|
||||
"""Reads for any authenticated user; mutations only for superadmin.
|
||||
|
||||
@@ -71,7 +139,7 @@ class FlockViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class CycleViewSet(viewsets.ModelViewSet):
|
||||
queryset = Cycle.objects.select_related("kandang").all()
|
||||
queryset = Cycle.objects.select_related("kandang", "kandang__site", "close_requested_by").all()
|
||||
serializer_class = CycleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -83,6 +151,87 @@ class CycleViewSet(viewsets.ModelViewSet):
|
||||
qs = qs.filter(status=params["status"])
|
||||
return qs
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="request-close")
|
||||
def request_close(self, request, pk=None):
|
||||
cycle = self.get_object()
|
||||
raw = request.data.get("proposed_end_date")
|
||||
proposed = parse_date(str(raw)) if raw else None
|
||||
if proposed is None:
|
||||
return Response(
|
||||
{"detail": "proposed_end_date is required (YYYY-MM-DD)."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
cycle.request_close(request.user, proposed)
|
||||
except ValidationError as exc:
|
||||
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
notify_pusat_close_request(cycle, cancelled=False)
|
||||
except PusatCloseError as exc:
|
||||
_revert_pending_close(cycle)
|
||||
return Response(
|
||||
{"detail": exc.message},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
return Response(CycleSerializer(cycle).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="cancel-close-request")
|
||||
def cancel_close_request(self, request, pk=None):
|
||||
cycle = self.get_object()
|
||||
snapshot = {
|
||||
"proposed_end_date": cycle.proposed_end_date,
|
||||
"close_requested_at": cycle.close_requested_at,
|
||||
"close_requested_by_id": cycle.close_requested_by_id,
|
||||
}
|
||||
try:
|
||||
cycle.cancel_close_request(request.user)
|
||||
except ValidationError as exc:
|
||||
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
notify_pusat_close_request(cycle, cancelled=True)
|
||||
except PusatCloseError as exc:
|
||||
_restore_pending_close(cycle, **snapshot)
|
||||
return Response(
|
||||
{"detail": exc.message},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
return Response(CycleSerializer(cycle).data)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="apply-close-decision",
|
||||
authentication_classes=[],
|
||||
permission_classes=[IsPusatCloseCallback],
|
||||
)
|
||||
def apply_close_decision(self, request, pk=None):
|
||||
"""Machine endpoint: pusat pushes approve/reject via shared PUSAT_API_KEY."""
|
||||
cycle = self.get_object()
|
||||
decision = str(request.data.get("decision", "")).strip().lower()
|
||||
if decision not in {"approve", "reject"}:
|
||||
return Response(
|
||||
{"detail": "decision must be 'approve' or 'reject'."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
end_date = None
|
||||
if decision == "approve":
|
||||
raw = request.data.get("end_date")
|
||||
end_date = parse_date(str(raw)) if raw else None
|
||||
if end_date is None:
|
||||
return Response(
|
||||
{"detail": "end_date is required when decision is approve."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
cycle.apply_close_decision(approved=(decision == "approve"), end_date=end_date)
|
||||
except ValidationError as exc:
|
||||
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(CycleSerializer(cycle).data)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="initial-balance-compare")
|
||||
def compare_initial_balance(self, request, pk=None):
|
||||
"""Pull IoT masuk for a date and compare against the cycle saldo awal."""
|
||||
|
||||
@@ -5,11 +5,12 @@ from apps.farms.models import Cycle
|
||||
from apps.operations.services.chicken_counting_edge import (
|
||||
ChickenCountingEdgeError,
|
||||
sync_chicken_counting_for_cycle,
|
||||
sync_chicken_weight_for_cycle,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Sync chicken counting + mortality IoT from the edge vision API"
|
||||
help = "Sync chicken counting, mortality, and weight from the edge vision API"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--cycle-id", type=int, default=None)
|
||||
@@ -17,7 +18,8 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
counting_on = settings.CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED
|
||||
mortality_on = settings.CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED
|
||||
if not counting_on and not mortality_on:
|
||||
weight_on = settings.CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED
|
||||
if not counting_on and not mortality_on and not weight_on:
|
||||
self.stdout.write(
|
||||
"CHICKEN_COUNTING_EDGE_*_SYNC_ENABLED=false; skipping"
|
||||
)
|
||||
@@ -32,14 +34,27 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
for cycle in cycles:
|
||||
try:
|
||||
summary = sync_chicken_counting_for_cycle(cycle)
|
||||
except ChickenCountingEdgeError as exc:
|
||||
self.stderr.write(f"cycle={cycle.pk}: {exc}")
|
||||
continue
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"cycle={cycle.pk} upserted={summary['upserted']} "
|
||||
f"dates={','.join(d.isoformat() for d in summary['dates']) or '-'}"
|
||||
)
|
||||
)
|
||||
if counting_on or mortality_on:
|
||||
try:
|
||||
summary = sync_chicken_counting_for_cycle(cycle)
|
||||
except ChickenCountingEdgeError as exc:
|
||||
self.stderr.write(f"cycle={cycle.pk} counting: {exc}")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"cycle={cycle.pk} counting upserted={summary['upserted']} "
|
||||
f"dates={','.join(d.isoformat() for d in summary['dates']) or '-'}"
|
||||
)
|
||||
)
|
||||
if weight_on:
|
||||
try:
|
||||
w_summary = sync_chicken_weight_for_cycle(cycle)
|
||||
except ChickenCountingEdgeError as exc:
|
||||
self.stderr.write(f"cycle={cycle.pk} weight: {exc}")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"cycle={cycle.pk} weight upserted={w_summary['upserted']} "
|
||||
f"dates={','.join(d.isoformat() for d in w_summary['dates']) or '-'}"
|
||||
)
|
||||
)
|
||||
@@ -12,7 +12,7 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.farms.models import Cycle
|
||||
from apps.operations.models import ChickenCounting
|
||||
from apps.operations.models import ChickenCounting, ChickenWeight
|
||||
|
||||
|
||||
class ChickenCountingEdgeError(Exception):
|
||||
@@ -87,10 +87,47 @@ def aggregate_mortality_by_date(
|
||||
return by_date
|
||||
|
||||
|
||||
def aggregate_weight_by_date(
|
||||
rows: list[dict[str, Any]] | None,
|
||||
*,
|
||||
kandang_name: str,
|
||||
) -> dict[date, dict[str, float | int]]:
|
||||
"""Map date → fused weight metrics for the matching coop.
|
||||
|
||||
Prefer ``camera_id=FUSED``. When both floors (e.g. K1-L1 / K1-L2) carry the
|
||||
same fused prediction, average weight/uniformity and take max sample count
|
||||
so duplicates are not double-counted.
|
||||
"""
|
||||
expected = coop_for_kandang(kandang_name).casefold()
|
||||
floors_by_date: dict[date, list[dict[str, Any]]] = {}
|
||||
for row in rows or []:
|
||||
if str(row.get("camera_id") or "").strip().upper() != "FUSED":
|
||||
continue
|
||||
day = _parse_history_date(row.get("date"))
|
||||
if day is None:
|
||||
continue
|
||||
coop = str(row.get("coop") or "").strip()
|
||||
if coop.casefold() != expected:
|
||||
continue
|
||||
floors_by_date.setdefault(day, []).append(row)
|
||||
|
||||
by_date: dict[date, dict[str, float | int]] = {}
|
||||
for day, floors in floors_by_date.items():
|
||||
weights = [float(f.get("predicted_weight_g") or 0) for f in floors]
|
||||
unifs = [float(f.get("uniformity") or 0) for f in floors]
|
||||
samples = [int(f.get("n_valid_samples") or 0) for f in floors]
|
||||
by_date[day] = {
|
||||
"average_weight": sum(weights) / len(weights),
|
||||
"uniformity": sum(unifs) / len(unifs),
|
||||
"chicken_count": max(samples) if samples else 0,
|
||||
}
|
||||
return by_date
|
||||
|
||||
|
||||
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
|
||||
if today is None:
|
||||
today = timezone.localdate()
|
||||
end = min(today, cycle.end_date)
|
||||
end = cycle.operational_end_date(today=today)
|
||||
return cycle.start_date, end
|
||||
|
||||
|
||||
@@ -99,6 +136,7 @@ class ChickenCountingEdgeClient:
|
||||
|
||||
DB_HISTORY_PATH = "/api/db/history"
|
||||
MORTALITY_HISTORY_PATH = "/api/mortality/history"
|
||||
WEIGHT_PREDICTIONS_PATH = "/api/weight/predictions"
|
||||
|
||||
def __init__(self, base_url: str | None = None, timeout: float | None = None):
|
||||
self.base_url = (
|
||||
@@ -136,6 +174,12 @@ class ChickenCountingEdgeClient:
|
||||
return payload
|
||||
return list(payload.get("results") or payload.get("history") or [])
|
||||
|
||||
def fetch_weight_predictions(self) -> list[dict[str, Any]]:
|
||||
payload = self._get_json(self.WEIGHT_PREDICTIONS_PATH)
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return list(payload.get("predictions") or payload.get("results") or [])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_chicken_counting_for_cycle(
|
||||
@@ -212,3 +256,77 @@ def sync_chicken_counting_for_cycle(
|
||||
upserted.append(day)
|
||||
|
||||
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_chicken_weight_for_cycle(
|
||||
cycle: Cycle,
|
||||
*,
|
||||
predictions: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert ChickenWeight from edge weight predictions within the cycle window.
|
||||
|
||||
Day 0 (cycle start) always uses DOC weight and 0% uniformity — edge values
|
||||
for that date are ignored.
|
||||
"""
|
||||
if not bool(getattr(settings, "CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED", True)):
|
||||
return {"dates": [], "upserted": 0, "skipped": True}
|
||||
|
||||
client = ChickenCountingEdgeClient()
|
||||
if predictions is None:
|
||||
predictions = client.fetch_weight_predictions()
|
||||
|
||||
kandang_name = cycle.kandang.kandang_name
|
||||
weight_by_date = aggregate_weight_by_date(predictions, kandang_name=kandang_name)
|
||||
|
||||
start, end = cycle_history_window(cycle)
|
||||
if start > end:
|
||||
return {"dates": [], "upserted": 0, "skipped": False}
|
||||
|
||||
doc_weight = int(cycle.doc_in_weight)
|
||||
dates = sorted({d for d in weight_by_date if start <= d <= end} | {start})
|
||||
|
||||
prev_weight = float(doc_weight)
|
||||
# Seed previous-day weight from any row before the first upsert date.
|
||||
existing_before = (
|
||||
ChickenWeight.objects.filter(cycle=cycle, date__lt=dates[0])
|
||||
.order_by("-date")
|
||||
.first()
|
||||
)
|
||||
if existing_before is not None:
|
||||
prev_weight = float(existing_before.average_weight)
|
||||
|
||||
upserted: list[date] = []
|
||||
for day in dates:
|
||||
age = (day - start).days
|
||||
if age == 0:
|
||||
average_weight = float(doc_weight)
|
||||
uniformity = 0.0
|
||||
chicken_count = int(cycle.doc_in_count)
|
||||
adg = 0.0
|
||||
else:
|
||||
metrics = weight_by_date.get(day)
|
||||
if metrics is None:
|
||||
continue
|
||||
average_weight = float(metrics["average_weight"])
|
||||
uniformity = float(metrics["uniformity"])
|
||||
chicken_count = int(metrics["chicken_count"])
|
||||
adg = round(average_weight - prev_weight, 2)
|
||||
|
||||
defaults = {
|
||||
"age": age,
|
||||
"doc_weight": doc_weight,
|
||||
"average_weight": average_weight,
|
||||
"chicken_count": chicken_count,
|
||||
"uniformity": uniformity,
|
||||
"average_daily_gain": adg,
|
||||
}
|
||||
ChickenWeight.objects.update_or_create(
|
||||
cycle=cycle,
|
||||
date=day,
|
||||
defaults=defaults,
|
||||
)
|
||||
upserted.append(day)
|
||||
prev_weight = average_weight
|
||||
|
||||
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
|
||||
@@ -104,10 +104,10 @@ def daily_totals_from_combined_history(
|
||||
|
||||
|
||||
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
|
||||
"""Inclusive date window for history upserts."""
|
||||
"""Inclusive date window for history upserts (calendar today while open)."""
|
||||
if today is None:
|
||||
today = timezone.localdate()
|
||||
end = min(today, cycle.end_date)
|
||||
end = cycle.operational_end_date(today=today)
|
||||
return cycle.start_date, end
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,98 @@ from apps.operations.services.chicken_counting_edge import (
|
||||
ChickenCountingEdgeError,
|
||||
aggregate_counting_by_date,
|
||||
aggregate_mortality_by_date,
|
||||
aggregate_weight_by_date,
|
||||
coop_for_kandang,
|
||||
location_prefix_for_kandang,
|
||||
sync_chicken_counting_for_cycle,
|
||||
sync_chicken_weight_for_cycle,
|
||||
)
|
||||
|
||||
WEIGHT_PREDICTIONS_SAMPLE = [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 103,
|
||||
"predicted_weight_g": 1626.9,
|
||||
"doc_weight_g": 42.0,
|
||||
"n_valid_samples": 3215,
|
||||
"coop": "K1",
|
||||
"location": "K1-L1",
|
||||
"uniformity": 85.0,
|
||||
},
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 103,
|
||||
"predicted_weight_g": 1626.9,
|
||||
"doc_weight_g": 42.0,
|
||||
"n_valid_samples": 3215,
|
||||
"coop": "K1",
|
||||
"location": "K1-L2",
|
||||
"uniformity": 85.0,
|
||||
},
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"camera_id": "CC1",
|
||||
"cycle_day": 103,
|
||||
"predicted_weight_g": 1600.0,
|
||||
"n_valid_samples": 100,
|
||||
"coop": "K1",
|
||||
"location": "K1-L1",
|
||||
"uniformity": 80.0,
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 1,
|
||||
"predicted_weight_g": 45.3,
|
||||
"n_valid_samples": 18580,
|
||||
"coop": "K1",
|
||||
"location": "K1-L1",
|
||||
"uniformity": 18.7,
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 1,
|
||||
"predicted_weight_g": 45.3,
|
||||
"n_valid_samples": 18580,
|
||||
"coop": "K1",
|
||||
"location": "K1-L2",
|
||||
"uniformity": 18.7,
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 1,
|
||||
"predicted_weight_g": 99.0,
|
||||
"n_valid_samples": 10,
|
||||
"coop": "K2",
|
||||
"location": "K2-L1",
|
||||
"uniformity": 50.0,
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 2,
|
||||
"predicted_weight_g": 58.2,
|
||||
"n_valid_samples": 20751,
|
||||
"coop": "K1",
|
||||
"location": "K1-L1",
|
||||
"uniformity": 17.2,
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"camera_id": "FUSED",
|
||||
"cycle_day": 0,
|
||||
"predicted_weight_g": 40.0,
|
||||
"n_valid_samples": 100,
|
||||
"coop": "K1",
|
||||
"location": "K1-L1",
|
||||
"uniformity": 90.0,
|
||||
},
|
||||
]
|
||||
|
||||
DB_HISTORY_SAMPLE = [
|
||||
{"date": "2026-09-02", "location": "K1-L1", "total": 14591},
|
||||
{"date": "2026-09-02", "location": "K1-L2", "total": 14591},
|
||||
@@ -54,6 +141,20 @@ class AggregationTests(TestCase):
|
||||
self.assertEqual(by_date[date(2026, 9, 2)], 0)
|
||||
self.assertEqual(by_date[date(2026, 9, 3)], 29)
|
||||
|
||||
def test_weight_uses_fused_coop_rows_and_averages_floors(self):
|
||||
by_date = aggregate_weight_by_date(
|
||||
WEIGHT_PREDICTIONS_SAMPLE, kandang_name="Kandang 1"
|
||||
)
|
||||
self.assertEqual(by_date[date(2026, 9, 2)]["average_weight"], 1626.9)
|
||||
self.assertEqual(by_date[date(2026, 9, 2)]["uniformity"], 85.0)
|
||||
# Identical floor duplicates must not double-count samples.
|
||||
self.assertEqual(by_date[date(2026, 9, 2)]["chicken_count"], 3215)
|
||||
self.assertEqual(by_date[date(2026, 9, 3)]["average_weight"], 45.3)
|
||||
self.assertEqual(by_date[date(2026, 9, 3)]["uniformity"], 18.7)
|
||||
self.assertNotIn(date(2026, 9, 3), aggregate_weight_by_date([], kandang_name="Kandang 1"))
|
||||
# K2 row must not leak into K1.
|
||||
self.assertNotEqual(by_date[date(2026, 9, 3)]["average_weight"], 99.0)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
|
||||
@@ -96,6 +197,42 @@ class SyncChickenCountingTests(TestCase):
|
||||
ChickenCounting.objects.filter(cycle=self.cycle, date=date(2026, 8, 20)).exists()
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
|
||||
@patch(
|
||||
"apps.operations.services.visibility.visible_through_date",
|
||||
return_value=date(2026, 9, 3), # publish cutoff still yesterday
|
||||
)
|
||||
def test_open_cycle_syncs_through_today_not_publish_cutoff(
|
||||
self, _mock_visible, mock_db, mock_mort, mock_today
|
||||
):
|
||||
"""Open cycles must ingest today's edge rows before DASHBOARD_PUBLISH_HOUR."""
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
open_cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=date(2026, 9, 2),
|
||||
end_date=None,
|
||||
doc_in_weight=35,
|
||||
doc_in_count=50000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
mock_db.return_value = DB_HISTORY_SAMPLE + [
|
||||
{"date": "2026-09-04", "location": "K1-L1", "total": 100},
|
||||
{"date": "2026-09-04", "location": "K1-L2", "total": 100},
|
||||
]
|
||||
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE + [
|
||||
{"date": "2026-09-04", "coop": "K1", "total_mortality_count": 3},
|
||||
]
|
||||
|
||||
summary = sync_chicken_counting_for_cycle(open_cycle)
|
||||
|
||||
self.assertIn(date(2026, 9, 4), summary["dates"])
|
||||
row = ChickenCounting.objects.get(cycle=open_cycle, date=date(2026, 9, 4))
|
||||
self.assertEqual(row.total_count, 200)
|
||||
self.assertEqual(row.mortality_count, 3)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
|
||||
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
|
||||
@@ -211,3 +348,141 @@ class ChickenCountingSyncApiTests(TestCase):
|
||||
w_results = weight.data["results"] if isinstance(weight.data, dict) else weight.data
|
||||
self.assertEqual(len(w_results), 1)
|
||||
self.assertEqual(w_results[0]["date"], "2026-09-04")
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=True,
|
||||
)
|
||||
class SyncChickenWeightTests(TestCase):
|
||||
def setUp(self):
|
||||
user = User.objects.create_user(user_name="weight-sync-user", password="secret")
|
||||
site = Site.objects.create(site_name="Sukawarna", user=user)
|
||||
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
|
||||
self.cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=33,
|
||||
start_date=date(2026, 9, 2),
|
||||
end_date=date(2026, 10, 4),
|
||||
doc_in_weight=35,
|
||||
doc_in_count=50000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch(
|
||||
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
|
||||
)
|
||||
def test_day0_uses_doc_weight_and_zero_uniformity(self, mock_pred, mock_today):
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
mock_pred.return_value = WEIGHT_PREDICTIONS_SAMPLE
|
||||
|
||||
summary = sync_chicken_weight_for_cycle(self.cycle)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(summary["dates"]),
|
||||
[date(2026, 9, 2), date(2026, 9, 3), date(2026, 9, 4)],
|
||||
)
|
||||
day0 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
|
||||
self.assertEqual(day0.age, 0)
|
||||
self.assertEqual(day0.doc_weight, 35)
|
||||
self.assertEqual(day0.average_weight, 35.0)
|
||||
self.assertEqual(day0.uniformity, 0.0)
|
||||
self.assertEqual(day0.average_daily_gain, 0.0)
|
||||
self.assertEqual(day0.chicken_count, 50000)
|
||||
|
||||
day1 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 3))
|
||||
self.assertEqual(day1.age, 1)
|
||||
self.assertEqual(day1.average_weight, 45.3)
|
||||
self.assertEqual(day1.uniformity, 18.7)
|
||||
self.assertAlmostEqual(day1.average_daily_gain, 10.3)
|
||||
self.assertEqual(day1.chicken_count, 18580)
|
||||
|
||||
day2 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 4))
|
||||
self.assertEqual(day2.average_weight, 58.2)
|
||||
self.assertAlmostEqual(day2.average_daily_gain, 12.9)
|
||||
self.assertFalse(
|
||||
ChickenWeight.objects.filter(cycle=self.cycle, date=date(2026, 8, 20)).exists()
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch(
|
||||
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
|
||||
)
|
||||
def test_creates_day0_even_without_edge_row(self, mock_pred, mock_today):
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
mock_pred.return_value = [
|
||||
row
|
||||
for row in WEIGHT_PREDICTIONS_SAMPLE
|
||||
if row["date"] != "2026-09-02"
|
||||
]
|
||||
|
||||
sync_chicken_weight_for_cycle(self.cycle)
|
||||
|
||||
day0 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
|
||||
self.assertEqual(day0.average_weight, 35.0)
|
||||
self.assertEqual(day0.uniformity, 0.0)
|
||||
|
||||
@patch(
|
||||
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
|
||||
)
|
||||
def test_skips_when_weight_sync_disabled(self, mock_pred):
|
||||
with override_settings(CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=False):
|
||||
summary = sync_chicken_weight_for_cycle(self.cycle)
|
||||
self.assertTrue(summary["skipped"])
|
||||
mock_pred.assert_not_called()
|
||||
self.assertEqual(ChickenWeight.objects.filter(cycle=self.cycle).count(), 0)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=True,
|
||||
)
|
||||
class ChickenWeightSyncApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(user_name="weight-api", password="secret")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
site = Site.objects.create(site_name="Sukawarna", user=self.user)
|
||||
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
|
||||
self.cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=33,
|
||||
start_date=date(2026, 9, 2),
|
||||
end_date=date(2026, 10, 4),
|
||||
doc_in_weight=35,
|
||||
doc_in_count=50000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
|
||||
@patch(
|
||||
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
|
||||
)
|
||||
def test_sync_action_upserts(self, mock_pred, mock_today):
|
||||
mock_today.return_value = date(2026, 9, 4)
|
||||
mock_pred.return_value = WEIGHT_PREDICTIONS_SAMPLE
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/chicken-weights/sync/",
|
||||
{"cycle_id": self.cycle.pk},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(ChickenWeight.objects.filter(cycle=self.cycle).count(), 3)
|
||||
day0 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
|
||||
self.assertEqual(day0.average_weight, 35.0)
|
||||
self.assertEqual(day0.uniformity, 0.0)
|
||||
|
||||
@patch(
|
||||
"apps.operations.views.sync_chicken_weight_for_cycle",
|
||||
side_effect=ChickenCountingEdgeError("edge down"),
|
||||
)
|
||||
def test_sync_action_returns_502_on_edge_error(self, _mock_sync):
|
||||
response = self.client.post(
|
||||
"/api/v1/chicken-weights/sync/",
|
||||
{"cycle_id": self.cycle.pk},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertIn("edge down", response.data["detail"])
|
||||
@@ -31,6 +31,7 @@ from apps.operations.services.karung_web import KarungWebError, sync_karung_for_
|
||||
from apps.operations.services.chicken_counting_edge import (
|
||||
ChickenCountingEdgeError,
|
||||
sync_chicken_counting_for_cycle,
|
||||
sync_chicken_weight_for_cycle,
|
||||
)
|
||||
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
|
||||
|
||||
@@ -119,6 +120,33 @@ class ChickenWeightViewSet(CycleScopedViewSet):
|
||||
def applies_visibility_filter(self) -> bool:
|
||||
return False
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="sync")
|
||||
def sync_from_edge(self, request):
|
||||
cycle_id = request.data.get("cycle_id")
|
||||
if not cycle_id:
|
||||
return Response(
|
||||
{"detail": "cycle_id is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
cycle = Cycle.objects.select_related("kandang").get(pk=cycle_id)
|
||||
except Cycle.DoesNotExist:
|
||||
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
summary = sync_chicken_weight_for_cycle(cycle)
|
||||
except (ChickenCountingEdgeError, ValidationError) as exc:
|
||||
code = getattr(exc, "status_code", None)
|
||||
return Response(
|
||||
{"detail": str(exc)},
|
||||
status=code if code and code >= 400 else status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"upserted": summary["upserted"],
|
||||
"dates": [d.isoformat() for d in summary["dates"]],
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="latest-avg")
|
||||
def latest_average(self, request):
|
||||
cycle_id = request.query_params.get("cycle_id")
|
||||
@@ -199,7 +227,7 @@ class IotPanelViewSet(viewsets.ModelViewSet):
|
||||
qs = qs.filter(
|
||||
flock__kandang_id=cycle.kandang_id,
|
||||
date__gte=cycle.start_date,
|
||||
date__lte=cycle.end_date,
|
||||
date__lte=cycle.operational_end_date(),
|
||||
)
|
||||
if params.get("flock_id"):
|
||||
qs = qs.filter(flock_id=params["flock_id"])
|
||||
|
||||
@@ -183,6 +183,11 @@ KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float(
|
||||
)
|
||||
KARUNG_WEB_ADMIN_SYNC_ENABLED = env_bool("KARUNG_WEB_ADMIN_SYNC_ENABLED", False)
|
||||
|
||||
# Pusat (executive) dedicated cycle-close channel.
|
||||
PUSAT_BASE_URL = (env("PUSAT_BASE_URL", "") or "").rstrip("/")
|
||||
PUSAT_API_KEY = env("PUSAT_API_KEY", "") or ""
|
||||
PUSAT_CITY_SITE_ID = env("PUSAT_CITY_SITE_ID", "") or ""
|
||||
|
||||
# Dashboard publish cutoff (WIB): day D visible from this time on day D.
|
||||
DASHBOARD_PUBLISH_HOUR = int(env("DASHBOARD_PUBLISH_HOUR", "17") or "17")
|
||||
DASHBOARD_PUBLISH_MINUTE = int(env("DASHBOARD_PUBLISH_MINUTE", "0") or "0")
|
||||
@@ -209,3 +214,6 @@ CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED = env_bool(
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED = env_bool(
|
||||
"CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", True
|
||||
)
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED = env_bool(
|
||||
"CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED", True
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- PostgreSQL Schema — CPSP Dashboard ERD
|
||||
-- Source: ERD_dashboard.png (updated 2026-08-20)
|
||||
-- Source: ERD_dashboard.png (updated 2026-09-09 for GM cycle-close flow)
|
||||
--
|
||||
-- user_access.status: active | inactive | superadmin
|
||||
-- (Django syncs is_active/is_staff/is_superuser from status on save:
|
||||
@@ -8,8 +8,9 @@
|
||||
-- is_staff false; active => is_active true, is_superuser false, is_staff
|
||||
-- left untouched so operators can be marked staff)
|
||||
-- user_access → sites → kandang → cycles
|
||||
-- cycles → flock (flock.cycle_id, required; cycles has no flock_id)
|
||||
-- flock → iot_panel
|
||||
-- cycles.status: active | pending_close | closed (GM approves end_date)
|
||||
-- cycles.close_requested_by_id → user_access (optional)
|
||||
-- kandang → flock → iot_panel
|
||||
-- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight,
|
||||
-- manual_input, kpi
|
||||
-- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input
|
||||
@@ -78,40 +79,48 @@ CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. Cycles
|
||||
-- No flock_id — flock belongs to cycle via flock.cycle_id.
|
||||
-- end_date is nullable until GM approves close (pending_close → closed).
|
||||
-- Soft bound while open uses dashboard visible-through (app-side).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS cycles (
|
||||
cycle_id SERIAL PRIMARY KEY,
|
||||
total_days INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
doc_in_weight INTEGER NOT NULL,
|
||||
doc_in_count INTEGER NOT NULL,
|
||||
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
|
||||
cycle_id SERIAL PRIMARY KEY,
|
||||
total_days INTEGER NOT NULL DEFAULT 1,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE, -- set on GM approve; NULL while active / pending_close
|
||||
doc_in_weight INTEGER NOT NULL,
|
||||
doc_in_count INTEGER NOT NULL,
|
||||
feed_initial_balance INTEGER NOT NULL DEFAULT 0,
|
||||
feed_initial_balance_date DATE,
|
||||
status VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'active', -- active | pending_close | closed
|
||||
proposed_end_date DATE, -- staff proposed close date (cleared after decision)
|
||||
close_requested_at TIMESTAMP WITH TIME ZONE,
|
||||
close_requested_by_id INTEGER REFERENCES user_access (user_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_cycles_date_range CHECK (
|
||||
end_date IS NULL OR end_date >= start_date
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_kandang_id ON cycles (kandang_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles (start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_close_requested_by_id ON cycles (close_requested_by_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. Flock
|
||||
-- Belongs to kandang (persists across cycles); used by IoT panel.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS flock (
|
||||
flock_id SERIAL PRIMARY KEY,
|
||||
flock_name VARCHAR(30) NOT NULL,
|
||||
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
|
||||
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_flock_cycle_id ON flock (cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_flock_kandang_id ON flock (kandang_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. IoT Panel
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { formatDateId, formatDateRange, resolveCycleStatus } from '../utils/format.ts';
|
||||
import {
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
resolveCycleBoundDate,
|
||||
resolveCycleStatus,
|
||||
cycleLastDayIndex,
|
||||
} from '../utils/format.ts';
|
||||
import type { Cycle } from '../types/api.ts';
|
||||
|
||||
type Phase = { name: string; startDay: number; endDay: number; color: string };
|
||||
|
||||
function phasesFor(totalDays: number): Phase[] {
|
||||
const starterEnd = Math.min(10, totalDays);
|
||||
const growerEnd = Math.min(24, totalDays);
|
||||
const safeDays = Math.max(totalDays, 1);
|
||||
const starterEnd = Math.min(10, safeDays);
|
||||
const growerEnd = Math.min(24, safeDays);
|
||||
const phases: Phase[] = [
|
||||
{ name: 'Starter', startDay: 1, endDay: starterEnd, color: 'bg-red-400' },
|
||||
];
|
||||
@@ -19,11 +26,11 @@ function phasesFor(totalDays: number): Phase[] {
|
||||
color: 'bg-orange-400',
|
||||
});
|
||||
}
|
||||
if (totalDays > growerEnd) {
|
||||
if (safeDays > growerEnd) {
|
||||
phases.push({
|
||||
name: 'Finisher',
|
||||
startDay: growerEnd + 1,
|
||||
endDay: totalDays,
|
||||
endDay: safeDays,
|
||||
color: 'bg-yellow-500',
|
||||
});
|
||||
}
|
||||
@@ -32,6 +39,12 @@ function phasesFor(totalDays: number): Phase[] {
|
||||
|
||||
const statusConfig = {
|
||||
active: { label: 'Active', bg: 'bg-red-100', text: 'text-red-800', icon: 'fa-running' },
|
||||
pending_close: {
|
||||
label: 'Menunggu GM',
|
||||
bg: 'bg-amber-100',
|
||||
text: 'text-amber-800',
|
||||
icon: 'fa-hourglass-half',
|
||||
},
|
||||
closed: {
|
||||
label: 'Completed',
|
||||
bg: 'bg-green-100',
|
||||
@@ -41,10 +54,13 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
|
||||
const phases = phasesFor(cycle.total_days);
|
||||
const currentDay = Math.min(Math.max(cycle.current_day, 0), cycle.total_days);
|
||||
const boundDate = resolveCycleBoundDate(cycle);
|
||||
const displayTotalDays = Math.max(cycle.total_days || 1, cycleLastDayIndex(cycle) + 1);
|
||||
const phases = phasesFor(displayTotalDays);
|
||||
const currentDay = Math.min(Math.max(cycle.current_day, 0), displayTotalDays);
|
||||
const cycleStatus = resolveCycleStatus(cycle);
|
||||
const progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
|
||||
const progress = displayTotalDays > 0 ? (currentDay / displayTotalDays) * 100 : 0;
|
||||
const showMarker = cycleStatus === 'active' || cycleStatus === 'pending_close';
|
||||
|
||||
return (
|
||||
<div className="pt-4">
|
||||
@@ -54,14 +70,16 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
|
||||
<span className="font-semibold">{formatDateRange(cycle.start_date, cycle.end_date)}</span>
|
||||
</p>
|
||||
<div className="text-sm sm:text-right">
|
||||
<p className="font-semibold text-gray-700">Estimasi Panen</p>
|
||||
<p className="text-red-600 font-bold">{formatDateId(cycle.end_date)}</p>
|
||||
<p className="font-semibold text-gray-700">
|
||||
{cycle.end_date ? 'Estimasi Panen' : 'Batas data'}
|
||||
</p>
|
||||
<p className="text-red-600 font-bold">{formatDateId(cycle.end_date ?? boundDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative pt-8">
|
||||
<div className="relative h-8 bg-gray-200 rounded-lg flex items-center shadow-inner overflow-hidden">
|
||||
{phases.map((phase) => {
|
||||
const width = ((phase.endDay - phase.startDay + 1) / cycle.total_days) * 100;
|
||||
const width = ((phase.endDay - phase.startDay + 1) / displayTotalDays) * 100;
|
||||
return (
|
||||
<div
|
||||
key={phase.name}
|
||||
@@ -74,7 +92,7 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{cycleStatus === 'active' && (
|
||||
{showMarker && (
|
||||
<div
|
||||
className="absolute top-0 transition-all duration-500 ease-out"
|
||||
style={{ left: `calc(${progress}% - 2.5rem)` }}
|
||||
@@ -101,9 +119,9 @@ const SingleCycleView: React.FC<{ cycle: Cycle }> = ({ cycle }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span>Hari {cycle.total_days}</span>
|
||||
<span>Hari {displayTotalDays}</span>
|
||||
<span className="text-xs font-normal text-gray-400">
|
||||
{formatDateId(cycle.end_date)}
|
||||
{formatDateId(cycle.end_date ?? boundDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,7 +226,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-2">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle={`${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)} (${cycleStatus === 'active' ? 'aktif' : '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
|
||||
cycleClosed={cycleStatus === 'closed'}
|
||||
|
||||
@@ -145,7 +145,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
) : (
|
||||
<>
|
||||
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
||||
Sync hari ini
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -170,6 +170,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
Aktif
|
||||
</span>
|
||||
)}
|
||||
{cycleStatus === 'pending_close' && (
|
||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
|
||||
Menunggu GM
|
||||
</span>
|
||||
)}
|
||||
{cycleStatus === 'closed' && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
|
||||
Selesai
|
||||
@@ -210,6 +215,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
Aktif
|
||||
</span>
|
||||
)}
|
||||
{itemStatus === 'pending_close' && (
|
||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
|
||||
Menunggu GM
|
||||
</span>
|
||||
)}
|
||||
{itemStatus === 'closed' && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
|
||||
Selesai
|
||||
|
||||
@@ -93,7 +93,12 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.id}>
|
||||
{formatDateId(cycle.start_date)} (
|
||||
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'})
|
||||
{resolveCycleStatus(cycle) === 'active'
|
||||
? 'Aktif'
|
||||
: resolveCycleStatus(cycle) === 'pending_close'
|
||||
? 'Menunggu GM'
|
||||
: 'Selesai'}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
+73
-30
@@ -4,21 +4,19 @@ import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import type { Cycle, FeedInButtonUrl } from '../types/api.ts';
|
||||
import { Banner, PageHeader } from './ui/Feedback.tsx';
|
||||
import { addDaysIso, formatDateId, inclusiveDayCount, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
||||
import { formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
||||
|
||||
const inputClass =
|
||||
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
|
||||
|
||||
const defaultCycleForm = () => ({
|
||||
startDate: todayIso(),
|
||||
endDate: addDaysIso(todayIso(), 34),
|
||||
docInCount: 20000,
|
||||
chickInWeight: 35,
|
||||
});
|
||||
|
||||
const cycleToForm = (cycle: Cycle) => ({
|
||||
startDate: cycle.start_date,
|
||||
endDate: cycle.end_date,
|
||||
docInCount: cycle.doc_in_count,
|
||||
chickInWeight: cycle.chick_in_weight,
|
||||
});
|
||||
@@ -48,7 +46,7 @@ const SettingsPage: React.FC = () => {
|
||||
const [flockName, setFlockName] = useState('');
|
||||
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
|
||||
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
|
||||
const [endDate, setEndDate] = useState(defaultCycleForm().endDate);
|
||||
const [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
|
||||
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
|
||||
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
|
||||
|
||||
@@ -56,7 +54,6 @@ const SettingsPage: React.FC = () => {
|
||||
const defaults = defaultCycleForm();
|
||||
setEditingCycleId(null);
|
||||
setStartDate(defaults.startDate);
|
||||
setEndDate(defaults.endDate);
|
||||
setDocInCount(defaults.docInCount);
|
||||
setChickInWeight(defaults.chickInWeight);
|
||||
};
|
||||
@@ -65,7 +62,6 @@ const SettingsPage: React.FC = () => {
|
||||
const form = cycleToForm(cycle);
|
||||
setEditingCycleId(cycle.id);
|
||||
setStartDate(form.startDate);
|
||||
setEndDate(form.endDate);
|
||||
setDocInCount(form.docInCount);
|
||||
setChickInWeight(form.chickInWeight);
|
||||
setSelectedCycleId(cycle.id);
|
||||
@@ -73,9 +69,8 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const buildCyclePayload = () => ({
|
||||
kandang: selectedKandangId!,
|
||||
total_days: inclusiveDayCount(startDate, endDate),
|
||||
total_days: 1,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
chick_in_weight: chickInWeight,
|
||||
doc_in_count: docInCount,
|
||||
});
|
||||
@@ -86,8 +81,19 @@ const SettingsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const isSuperAdmin = user?.status === 'superadmin';
|
||||
const canRequestClose = user?.status === 'active';
|
||||
|
||||
const statusLabel = (cycle: Cycle) => {
|
||||
const status = resolveCycleStatus(cycle);
|
||||
if (status === 'pending_close') return 'Menunggu persetujuan GM';
|
||||
if (status === 'closed') return 'Selesai';
|
||||
return 'Aktif';
|
||||
};
|
||||
|
||||
const selectedKandang = kandangs.find((k) => k.id === selectedKandangId) ?? null;
|
||||
const editingCycle = editingCycleId
|
||||
? (cycles.find((c) => c.id === editingCycleId) ?? null)
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const urls = selectedKandang?.feed_in_button_urls ?? [];
|
||||
@@ -419,32 +425,25 @@ const SettingsPage: React.FC = () => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{editingCycleId && (
|
||||
{editingCycle && (
|
||||
<p className="text-xs font-medium text-blue-700 bg-blue-50 border border-blue-100 rounded-md px-3 py-2">
|
||||
Mengedit siklus {formatDateId(startDate)} – {formatDateId(endDate)}
|
||||
Mengedit siklus mulai {formatDateId(startDate)}
|
||||
{editingCycle.end_date
|
||||
? ` (selesai ${formatDateId(editingCycle.end_date)}, hanya via GM)`
|
||||
: ' — tanggal selesai ditetapkan setelah persetujuan GM'}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
Tanggal selesai tidak diisi di sini. Ajukan tutup siklus setelah produksi selesai; GM
|
||||
yang menyetujui end date.
|
||||
</p>
|
||||
<label className="block text-sm">
|
||||
Mulai
|
||||
<input
|
||||
type="date"
|
||||
className={`${inputClass} mt-1`}
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
const nextStart = e.target.value;
|
||||
setStartDate(nextStart);
|
||||
if (endDate < nextStart) setEndDate(nextStart);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Selesai
|
||||
<input
|
||||
type="date"
|
||||
className={`${inputClass} mt-1`}
|
||||
min={startDate}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
@@ -490,17 +489,60 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</form>
|
||||
<ul className="divide-y">
|
||||
{cycles.map((cycle) => (
|
||||
{cycles.map((cycle) => {
|
||||
const status = resolveCycleStatus(cycle);
|
||||
return (
|
||||
<li key={cycle.id} className="py-2 text-sm text-gray-700">
|
||||
<div className="flex justify-between gap-2">
|
||||
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
|
||||
{formatDateId(cycle.start_date)} – {formatDateId(cycle.end_date)}
|
||||
{formatDateId(cycle.start_date)}
|
||||
{cycle.end_date ? ` – ${formatDateId(cycle.end_date)}` : ''}
|
||||
<span className="block text-xs text-gray-400">
|
||||
{resolveCycleStatus(cycle) === 'active' ? 'Aktif' : 'Selesai'} ·{' '}
|
||||
{cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
|
||||
{statusLabel(cycle)}
|
||||
{status === 'pending_close' && cycle.proposed_end_date
|
||||
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
|
||||
: ''}{' '}
|
||||
· {cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
|
||||
</span>
|
||||
</button>
|
||||
<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
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
onClick={() => beginCycleEdit(cycle)}
|
||||
@@ -522,7 +564,8 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatDateId, formatNumber } from '../utils/format.ts';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { formatDateId, formatDateRange, formatNumber, resolveCycleStatus } from '../utils/format.ts';
|
||||
import { formatWeight } from '../utils/weight.ts';
|
||||
import WeightStats from './weight/WeightStats.tsx';
|
||||
import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx';
|
||||
@@ -11,15 +11,18 @@ import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx';
|
||||
import DailyDetailPanel from './weight/DailyDetailPanel.tsx';
|
||||
|
||||
const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const { selectedCycle } = useFarm();
|
||||
const { selectedCycle, selectedSite, selectedKandang } = useFarm();
|
||||
const [rows, setRows] = useState<ChickenWeight[]>([]);
|
||||
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
|
||||
const isClosedCycle = !selectedCycle || resolveCycleStatus(selectedCycle) !== 'active';
|
||||
|
||||
const reload = async (cycleId: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -51,7 +54,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setRefreshing(false);
|
||||
try {
|
||||
await reload(selectedCycle.id);
|
||||
} finally {
|
||||
@@ -78,15 +80,19 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
}
|
||||
}, [rows, chartRows, selectedDate]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!selectedCycle) return;
|
||||
setRefreshing(true);
|
||||
const handleSync = async () => {
|
||||
if (!selectedCycle || isClosedCycle) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.weights.sync({ cycle_id: selectedCycle.id });
|
||||
setNotice('Sinkron berat edge berhasil.');
|
||||
await reload(selectedCycle.id);
|
||||
} catch {
|
||||
// error state is set inside reload
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,41 +136,42 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-red-600 text-white p-3 rounded-lg mr-4">
|
||||
<i className="fa-solid fa-chart-line text-2xl sm:text-3xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-800">Dashboard Berat Ayam</h1>
|
||||
<p className="mt-1 text-gray-600">
|
||||
Analisis pertumbuhan dan kesejahteraan bobot ayam secara real-time.
|
||||
</p>
|
||||
{!loading && latest?.date && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<i className="fa-solid fa-clock mr-1"></i>
|
||||
Terakhir diperbarui: {formatDateId(latest.date)}
|
||||
</p>
|
||||
<PageHeader
|
||||
title="Dashboard Berat Ayam"
|
||||
icon={<i className="fa-solid fa-chart-line" />}
|
||||
subtitle={`Analisis pertumbuhan bobot ayam secara real-time · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing || isClosedCycle}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-60 hover:bg-red-700 transition-colors"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<i className="fa-solid fa-spinner fa-spin mr-2"></i>
|
||||
Sync...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa-solid fa-arrows-rotate mr-2"></i>
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
title="Refresh data manual"
|
||||
>
|
||||
<i className={`fa-solid fa-refresh ${refreshing || loading ? 'fa-spin' : ''}`}></i>
|
||||
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="error">{error}</Banner>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="mb-4">
|
||||
<Banner kind="success">{notice}</Banner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<Spinner />
|
||||
@@ -297,9 +304,9 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-2">Informasi Pembaruan Data</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• Klik tombol "Refresh" untuk memperbarui data secara manual</li>
|
||||
<li>• Grafik menampilkan data dari awal siklus hingga hari ini</li>
|
||||
<li>• Target berat menggunakan standar performa Ross 308</li>
|
||||
<li>• Klik "Sync" untuk menarik prediksi berat dari edge vision</li>
|
||||
<li>• Hari 0 memakai berat DOC dan uniformity 0% (bukan data edge)</li>
|
||||
<li>• Target berat menggunakan standar performa berdasarkan DOC</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,18 +188,17 @@ describe('SettingsPage settings forms', () => {
|
||||
|
||||
expect(screen.queryByLabelText(/^Siklus$/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Selesai/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
|
||||
fireEvent.change(screen.getByLabelText(/Selesai/i), { target: { value: '2026-09-04' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tambah siklus' }));
|
||||
|
||||
await waitFor(() => expect(createCycleMock).toHaveBeenCalledTimes(1));
|
||||
expect(createCycleMock).toHaveBeenCalledWith({
|
||||
kandang: 11,
|
||||
total_days: 35,
|
||||
total_days: 1,
|
||||
current_day: 1,
|
||||
start_date: '2026-08-01',
|
||||
end_date: '2026-09-04',
|
||||
chick_in_weight: 35,
|
||||
doc_in_count: 20000,
|
||||
});
|
||||
@@ -219,7 +218,7 @@ describe('SettingsPage settings forms', () => {
|
||||
current_day: 1,
|
||||
visible_through_date: '2026-08-18',
|
||||
start_date: '2026-08-01',
|
||||
end_date: '2026-09-04',
|
||||
end_date: null,
|
||||
chick_in_weight: 35,
|
||||
doc_in_count: 20000,
|
||||
feed_initial_balance: 0,
|
||||
@@ -239,9 +238,8 @@ describe('SettingsPage settings forms', () => {
|
||||
await waitFor(() => expect(updateCycleMock).toHaveBeenCalledTimes(1));
|
||||
expect(updateCycleMock).toHaveBeenCalledWith(22, {
|
||||
kandang: 11,
|
||||
total_days: 35,
|
||||
total_days: 1,
|
||||
start_date: '2026-08-01',
|
||||
end_date: '2026-09-04',
|
||||
chick_in_weight: 38,
|
||||
doc_in_count: 18000,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx';
|
||||
import {
|
||||
addDaysIso,
|
||||
cycleDayForDate,
|
||||
cycleLastDayIndex,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
@@ -116,7 +117,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
|
||||
|
||||
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
|
||||
const endDay = cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date);
|
||||
const endDay = cycleLastDayIndex(selectedCycle);
|
||||
const calendarDay = cycleDayForDate(selectedCycle.start_date, todayIso());
|
||||
const maxDay = Math.min(calendarDay, lastCycleDay, endDay);
|
||||
const today = todayIso();
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
feedUseTotals,
|
||||
} from '../../utils/reportTotals.ts';
|
||||
import {
|
||||
cycleDayForDate,
|
||||
cycleLastDayIndex,
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
@@ -63,10 +63,7 @@ const ReportPreviewOverlay: React.FC<Props> = ({ open, reportType, onClose }) =>
|
||||
|
||||
const latestDay = selectedCycle ? Math.max(0, selectedCycle.current_day) : 0;
|
||||
const endCycleDay = selectedCycle
|
||||
? Math.min(
|
||||
selectedCycle.total_days,
|
||||
cycleDayForDate(selectedCycle.start_date, selectedCycle.end_date)
|
||||
)
|
||||
? Math.min(selectedCycle.total_days, cycleLastDayIndex(selectedCycle))
|
||||
: 0;
|
||||
const effectiveUpToDay = isEndCycle ? endCycleDay : draftDay;
|
||||
|
||||
|
||||
+2
-1
@@ -40,8 +40,9 @@ IOT_SYNC_LOOKBACK_MINUTES=20
|
||||
IOT_SYNC_MAX_PAGES=10
|
||||
IOT_FLOCK_ID_MAP={}
|
||||
|
||||
# Chicken counting edge vision API (counting + mortality; not gated by publish cutoff)
|
||||
# Chicken counting edge vision API (counting + mortality + weight; not gated by publish cutoff)
|
||||
CHICKEN_COUNTING_EDGE_BASE_URL=http://192.168.192.106:8090
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=true
|
||||
+1
-1
@@ -4,7 +4,7 @@ PATH=/usr/local/bin:/usr/bin:/bin
|
||||
# IoT panel: real-time sync every 10 minutes (not gated by 17:00 publish cutoff).
|
||||
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1
|
||||
|
||||
# Chicken counting + mortality edge sync every 10 minutes (not gated by publish cutoff).
|
||||
# Chicken counting + mortality + weight edge sync every 10 minutes (not gated by publish cutoff).
|
||||
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_chicken_counting_from_edge >> /var/log/cron.log 2>&1
|
||||
|
||||
# Karung-web-admin: every 30s via karung_sync_loop.sh (started by entrypoint-cron.sh).
|
||||
|
||||
@@ -194,6 +194,10 @@ export const api = {
|
||||
id: number,
|
||||
query: { date: string; manual?: number }
|
||||
) => get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
|
||||
requestClose: (id: number, body: { proposed_end_date: string }) =>
|
||||
post<Cycle>(`/cycles/${id}/request-close/`, body),
|
||||
cancelCloseRequest: (id: number) =>
|
||||
post<Cycle>(`/cycles/${id}/cancel-close-request/`, {}),
|
||||
},
|
||||
kpis: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
@@ -222,6 +226,8 @@ export const api = {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<ChickenWeight> | ChickenWeight[]>('/chicken-weights/', query)),
|
||||
latestAvg: (query?: CycleFilter) => get<LatestAvg>('/chicken-weights/latest-avg/', query),
|
||||
sync: (body: { cycle_id: number }) =>
|
||||
post<{ upserted: number; dates: string[] }>('/chicken-weights/sync/', body),
|
||||
},
|
||||
karungs: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
|
||||
+7
-4
@@ -45,7 +45,7 @@ export type Flock = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CycleStatus = 'active' | 'closed';
|
||||
export type CycleStatus = 'active' | 'pending_close' | 'closed';
|
||||
|
||||
export type Cycle = {
|
||||
id: number;
|
||||
@@ -54,13 +54,17 @@ export type Cycle = {
|
||||
total_days: number;
|
||||
current_day: number;
|
||||
visible_through_date: string;
|
||||
soft_end_date?: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
end_date: string | null;
|
||||
chick_in_weight: number;
|
||||
doc_in_count: number;
|
||||
feed_initial_balance: number;
|
||||
feed_initial_balance_date?: string | null;
|
||||
status: CycleStatus;
|
||||
proposed_end_date?: string | null;
|
||||
close_requested_at?: string | null;
|
||||
close_requested_by?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -233,10 +237,9 @@ export type FlockWrite = {
|
||||
};
|
||||
export type CycleWrite = {
|
||||
kandang: number;
|
||||
total_days: number;
|
||||
total_days?: number;
|
||||
current_day?: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
chick_in_weight: number;
|
||||
doc_in_count: number;
|
||||
feed_initial_balance?: number;
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ManualInput,
|
||||
} from '../types/api.ts';
|
||||
import type { DashboardReportPayload, DashboardReportType } from '../types/dashboardReport.ts';
|
||||
import { cycleDayForDate } from './format.ts';
|
||||
import { cycleDayForDate, cycleLastDayIndex, resolveCycleBoundDate } from './format.ts';
|
||||
|
||||
export type BuildInMemoryReportInput = {
|
||||
cycle: Cycle;
|
||||
@@ -48,9 +48,8 @@ function feedAccuracy(iot: number | null, manual: number | null): number | null
|
||||
export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardReportPayload {
|
||||
const { cycle } = input;
|
||||
// Day numbering stays 0-based (day 0 = start_date), but the report must never
|
||||
// run past the cycle end date. lastDay = start_date → end_date in days, which
|
||||
// is total_days - 1 for a contiguous cycle.
|
||||
const lastDay = cycleDayForDate(cycle.start_date, cycle.end_date);
|
||||
// run past the approved end (or soft bound while open).
|
||||
const lastDay = cycleLastDayIndex(cycle);
|
||||
const endCycleUpTo = Math.min(cycle.total_days, lastDay);
|
||||
const defaultUpTo =
|
||||
input.reportType === 'end_cycle'
|
||||
@@ -202,7 +201,7 @@ export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardR
|
||||
return {
|
||||
cycleId: String(cycle.id),
|
||||
cycleStart: cycle.start_date,
|
||||
cycleEnd: cycle.end_date,
|
||||
cycleEnd: cycle.end_date ?? resolveCycleBoundDate(cycle),
|
||||
kandangId: input.kandangId,
|
||||
kandangName: input.kandangName,
|
||||
area: input.area,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts';
|
||||
import { addDaysIso, cycleDayForDate } from './format.ts';
|
||||
import { addDaysIso, cycleDayForDate, cycleLastDayIndex } from './format.ts';
|
||||
|
||||
export const COUNTING_ACCURACY_WARNING_THRESHOLD = 95;
|
||||
|
||||
@@ -38,7 +38,7 @@ function stockAkhirByDate(
|
||||
const manualByDate = new Map(manualInputs.map((m) => [m.date, m]));
|
||||
|
||||
const lastCycleDay = Math.max(0, (cycle.total_days || 1) - 1);
|
||||
const endDay = cycleDayForDate(cycle.start_date, cycle.end_date);
|
||||
const endDay = cycleLastDayIndex(cycle);
|
||||
const maxDay = Math.min(lastCycleDay, endDay);
|
||||
|
||||
const result = new Map<string, number>();
|
||||
|
||||
+45
-6
@@ -21,13 +21,15 @@ export function formatRatio(value: number, decimals = 3): string {
|
||||
return truncated.toFixed(decimals);
|
||||
}
|
||||
|
||||
export function formatDateId(iso: string): string {
|
||||
export function formatDateId(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const date = new Date(`${iso}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateRange(startIso: string, endIso: string): string {
|
||||
export function formatDateRange(startIso: string, endIso?: string | null): string {
|
||||
if (!endIso) return formatDateId(startIso);
|
||||
return `${formatDateId(startIso)} – ${formatDateId(endIso)}`;
|
||||
}
|
||||
|
||||
@@ -55,9 +57,46 @@ export function inclusiveDayCount(startIso: string, endIso: string): number {
|
||||
return Math.max(1, cycleDayForDate(startIso, endIso) + 1);
|
||||
}
|
||||
|
||||
/** Inclusive upper date for open cycles (null end_date) without producing NaN day math. */
|
||||
export function resolveCycleBoundDate(cycle: {
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
soft_end_date?: string | null;
|
||||
visible_through_date?: string | null;
|
||||
total_days?: number;
|
||||
}): string {
|
||||
if (cycle.end_date) return cycle.end_date;
|
||||
if (cycle.soft_end_date) return cycle.soft_end_date;
|
||||
if (cycle.visible_through_date) return cycle.visible_through_date;
|
||||
if (cycle.total_days != null && cycle.total_days > 0) {
|
||||
return addDaysIso(cycle.start_date, Math.max(cycle.total_days - 1, 0));
|
||||
}
|
||||
return todayIso();
|
||||
}
|
||||
|
||||
/** 0-based last day index from start → bound date (safe when end_date is null). */
|
||||
export function cycleLastDayIndex(cycle: {
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
soft_end_date?: string | null;
|
||||
visible_through_date?: string | null;
|
||||
total_days?: number;
|
||||
}): number {
|
||||
return cycleDayForDate(cycle.start_date, resolveCycleBoundDate(cycle));
|
||||
}
|
||||
|
||||
export type CycleStatusUi = 'active' | 'pending_close' | 'closed';
|
||||
|
||||
export function resolveCycleStatus(cycle: {
|
||||
end_date: string;
|
||||
visible_through_date: string;
|
||||
}): 'active' | 'closed' {
|
||||
return cycle.visible_through_date >= cycle.end_date ? 'closed' : 'active';
|
||||
status?: string;
|
||||
end_date?: string | null;
|
||||
visible_through_date?: string;
|
||||
}): CycleStatusUi {
|
||||
if (cycle.status === 'pending_close' || cycle.status === 'closed' || cycle.status === 'active') {
|
||||
return cycle.status;
|
||||
}
|
||||
if (cycle.end_date && cycle.visible_through_date && cycle.visible_through_date >= cycle.end_date) {
|
||||
return 'closed';
|
||||
}
|
||||
return 'active';
|
||||
}
|
||||
Reference in new issue
Block a user