first commit

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

No files matched your search

+382
View File
@@ -0,0 +1,382 @@
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from apps.accounts.models import User
from apps.accounts.permissions import CanApproveCycleClose, can_approve_cycle_close
from apps.accounts.scoping import (
filter_cycles_queryset,
filter_flocks_queryset,
filter_kandangs_queryset,
filter_sites_queryset,
)
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.site_close import SiteClosePushError, push_close_decision
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.farms.services.city_registry import ensure_hq_site_for_city
from apps.sync.models import CitySite
def _validation_detail(exc: ValidationError):
if hasattr(exc, "message_dict"):
return exc.message_dict
if hasattr(exc, "messages"):
return "; ".join(str(m) for m in exc.messages)
return str(getattr(exc, "message", exc))
def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
source_cycle_id = payload.get("source_cycle_id")
if not source_cycle_id:
raise ValidationError("source_cycle_id is required.")
proposed = parse_date(str(payload.get("proposed_end_date") or ""))
if proposed is None:
raise ValidationError("proposed_end_date is required.")
start = parse_date(str(payload.get("start_date") or ""))
if start is None:
raise ValidationError("start_date is required.")
site_name = str(payload.get("site_name") or city.name)[:30]
kandang_name = str(payload.get("kandang_name") or "Kandang")[:30]
owner = city.registered_by
if owner is None:
owner = User.objects.filter(status=User.STATUS_GM).first()
if owner is None:
raise ValidationError("No GM available to own mirrored site.")
site = ensure_hq_site_for_city(city)
if site.site_name != site_name and not Site.objects.filter(
user=owner, site_name=site_name
).exclude(pk=site.pk).exists():
# Prefer ingest site_name when it does not collide.
site.site_name = site_name
site.save(update_fields=["site_name", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
site=site,
kandang_name=kandang_name,
defaults={},
)
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
if cycle is None:
cycle = Cycle(
kandang=kandang,
city_site=city,
source_cycle_id=source_cycle_id,
start_date=start,
end_date=None,
doc_in_weight=int(payload.get("doc_in_weight") or 0),
doc_in_count=int(payload.get("doc_in_count") or 0),
total_days=int(payload.get("total_days") or 1),
status=Cycle.STATUS_ACTIVE,
)
cycle.save()
else:
cycle.kandang = kandang
cycle.start_date = start
cycle.doc_in_weight = int(payload.get("doc_in_weight") or cycle.doc_in_weight)
cycle.doc_in_count = int(payload.get("doc_in_count") or cycle.doc_in_count)
cycle.save()
requested_dt = parse_datetime(str(payload.get("requested_at") or "")) or None
cycle.mark_pending_close(proposed, requested_at=requested_dt)
return cycle
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def cycle_close_ingest(request):
"""Site → pusat: register/update a pending close request."""
city_site_id = request.data.get("city_site_id")
try:
city = CitySite.objects.get(pk=city_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
try:
cycle = _upsert_mirror_cycle_from_ingest(city, request.data)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def cycle_close_cancel(request):
"""Site → pusat: cancel a pending close request."""
city_site_id = request.data.get("city_site_id")
source_cycle_id = request.data.get("source_cycle_id")
try:
city = CitySite.objects.get(pk=city_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
if cycle is None:
return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try:
cycle.cancel_pending_close()
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
class IsDirectorAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated executive; structure writes for director_admin.
Mirrored farm structures should normally arrive via city sync. Manual
create/edit/delete is reserved for director_admin (bootstrap / repair).
"""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if request.method in permissions.SAFE_METHODS:
return True
return bool(
request.user.is_superuser
or getattr(request.user, "status", None)
in {
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_SUPERUSER,
}
)
class SiteViewSet(viewsets.ModelViewSet):
queryset = Site.objects.select_related("user").all()
serializer_class = SiteSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_sites_queryset(super().get_queryset(), self.request.user)
user_id = self.request.query_params.get("user_id")
if user_id:
qs = qs.filter(user_id=user_id)
return qs
class KandangViewSet(viewsets.ModelViewSet):
queryset = Kandang.objects.select_related("site").all()
serializer_class = KandangSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_kandangs_queryset(super().get_queryset(), self.request.user)
site_id = self.request.query_params.get("site_id")
if site_id:
qs = qs.filter(site_id=site_id)
return qs
class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.select_related("kandang").all()
serializer_class = FlockSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_flocks_queryset(super().get_queryset(), self.request.user)
kandang_id = self.request.query_params.get("kandang_id")
if kandang_id:
qs = qs.filter(kandang_id=kandang_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related(
"kandang", "close_approved_by", "city_site", "kandang__site"
).all()
serializer_class = CycleSerializer
def get_queryset(self):
qs = filter_cycles_queryset(super().get_queryset(), self.request.user)
params = self.request.query_params
if params.get("kandang_id"):
qs = qs.filter(kandang_id=params["kandang_id"])
if params.get("status"):
qs = qs.filter(status=params["status"])
return qs
@action(
detail=True,
methods=["post"],
url_path="approve-close",
permission_classes=[CanApproveCycleClose],
)
def approve_close(self, request, pk=None):
"""GM-only: approve closing a cycle on the pusat mirror."""
if not can_approve_cycle_close(request.user):
return Response({"detail": "Only GM may approve cycle close."}, status=status.HTTP_403_FORBIDDEN)
cycle = self.get_object()
try:
cycle.approve_close(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
site_error = None
try:
push_close_decision(cycle, approved=True)
except SiteClosePushError as exc:
site_error = exc.message
data = CycleSerializer(cycle).data
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(data)
@action(
detail=True,
methods=["post"],
url_path="reject-close",
permission_classes=[CanApproveCycleClose],
)
def reject_close(self, request, pk=None):
"""GM-only: reject a pending close request."""
if not can_approve_cycle_close(request.user):
return Response({"detail": "Only GM may reject cycle close."}, status=status.HTTP_403_FORBIDDEN)
cycle = self.get_object()
try:
cycle.reject_close(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
site_error = None
try:
push_close_decision(cycle, approved=False)
except SiteClosePushError as exc:
site_error = exc.message
data = CycleSerializer(cycle).data
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(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."""
cycle = self.get_object()
date_str = request.query_params.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
manual_raw = request.query_params.get("manual")
if manual_raw is None:
feed_in_manual = cycle.feed_initial_balance
else:
try:
feed_in_manual = int(manual_raw)
except (TypeError, ValueError):
return Response(
{"detail": "manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error,
}
)
@action(detail=True, methods=["post"], url_path="initial-balance")
def set_initial_balance(self, request, pk=None):
"""Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation). Opening balance is kept on
the cycle for saldo only and is not written into daily FeedSacks rows.
Pulls the IoT-detected "in" for that date from karung-web-admin
``/api/combined`` and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
date_str = request.data.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
feed_in_manual = int(request.data.get("feed_in_manual"))
except (TypeError, ValueError):
return Response(
{"detail": "feed_in_manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
if feed_in_manual < 0:
return Response(
{"detail": "feed_in_manual cannot be negative."},
status=status.HTTP_400_BAD_REQUEST,
)
previous_date = cycle.feed_initial_balance_date
previous_balance = cycle.feed_initial_balance
cycle.feed_initial_balance = feed_in_manual
cycle.feed_initial_balance_date = balance_date
cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
# Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
cleanup_opening_balance_feed_rows(
cycle,
previous_date=previous_date,
previous_balance=previous_balance,
balance_date=balance_date,
balance_amount=feed_in_manual,
)
recompute_feed_sack_in_totals(cycle)
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)